blitzwing 0.1.10 → 0.2.1

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;
@@ -122,108 +117,77 @@ async function wizard(args) {
122
117
  process.exit(1);
123
118
  }
124
119
 
125
- const layers = await p.text({
126
- message: `How many layers can this machine host? (1–${maxLayers})`,
127
- initialValue: String(Math.min(8, maxLayers)),
128
- validate(v) {
129
- const n = Number(v);
130
- if (!Number.isInteger(n) || n < 1 || n > maxLayers) {
131
- return `Enter an integer between 1 and ${maxLayers}`;
132
- }
133
- },
134
- });
135
- if (p.isCancel(layers)) {
136
- p.cancel("Setup cancelled");
137
- process.exit(0);
138
- }
139
- const layersN = Number(layers);
140
-
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";
120
+ const nonInteractive =
121
+ process.env.BLITZWING_NONINTERACTIVE === "1" ||
122
+ process.env.BLITZWING_YES === "1" ||
123
+ !process.stdin.isTTY;
171
124
 
172
- if (networkMode === "public") {
173
- publicIp = await p.text({
174
- message: "Public IPv4 (must be reachable on TCP " + PETALS_PORT + ")",
175
- initialValue: detected || "",
125
+ let layersN;
126
+ if (nonInteractive && process.env.BLITZWING_LAYERS) {
127
+ layersN = Number(process.env.BLITZWING_LAYERS);
128
+ if (!Number.isInteger(layersN) || layersN < 1 || layersN > maxLayers) {
129
+ p.cancel(`BLITZWING_LAYERS must be an integer between 1 and ${maxLayers}`);
130
+ process.exit(1);
131
+ }
132
+ p.log.info(`Layers: ${layersN} (non-interactive)`);
133
+ } else {
134
+ const layers = await p.text({
135
+ message: `How many layers can this machine host? (1–${maxLayers})`,
136
+ initialValue: String(Math.min(8, maxLayers)),
176
137
  validate(v) {
177
- if (!v || !/^\d+\.\d+\.\d+\.\d+$/.test(String(v).trim())) {
178
- return "Enter a valid public IPv4 address";
138
+ const n = Number(v);
139
+ if (!Number.isInteger(n) || n < 1 || n > maxLayers) {
140
+ return `Enter an integer between 1 and ${maxLayers}`;
179
141
  }
180
142
  },
181
143
  });
182
- if (p.isCancel(publicIp)) {
144
+ if (p.isCancel(layers)) {
183
145
  p.cancel("Setup cancelled");
184
146
  process.exit(0);
185
147
  }
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);
148
+ layersN = Number(layers);
149
+ }
150
+
151
+ const hederaPrefill =
152
+ process.env.BLITZWING_HEDERA_ACCOUNT_ID ||
153
+ process.env.HEDERA_ACCOUNT_ID ||
154
+ existing?.hedera_account_id ||
155
+ "";
156
+ let hederaAccountId;
157
+ if (nonInteractive && hederaPrefill) {
158
+ hederaAccountId = String(hederaPrefill).trim();
159
+ if (!/^0\.0\.\d+$/.test(hederaAccountId)) {
160
+ p.cancel("BLITZWING_HEDERA_ACCOUNT_ID must look like 0.0.123456");
193
161
  process.exit(1);
194
162
  }
195
- announceMaddrs = ngrok;
196
- publicIp = ngrok.match(/dns4\/([^/]+)/)?.[1] || detected || "relay";
197
- useAutoRelay = "0";
198
- p.log.info(`Using ngrok announce ${announceMaddrs}`);
163
+ p.log.info(`Hedera payouts: ${hederaAccountId} (non-interactive)`);
199
164
  } 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
-
206
- const hederaAccount = await p.text({
207
- message: "Hedera account ID for layer payouts (e.g. 0.0.123456)",
208
- initialValue: process.env.BLITZWING_HEDERA_ACCOUNT_ID || "",
209
- validate(v) {
210
- const s = String(v || "").trim();
211
- if (!/^0\.0\.\d+$/.test(s)) return "Enter a Hedera account like 0.0.123456";
212
- },
213
- });
214
- if (p.isCancel(hederaAccount)) {
215
- p.cancel("Setup cancelled");
216
- process.exit(0);
165
+ const hederaAccount = await p.text({
166
+ message: "Hedera account ID for layer payouts (e.g. 0.0.123456)",
167
+ initialValue: hederaPrefill,
168
+ validate(v) {
169
+ const s = String(v || "").trim();
170
+ if (!/^0\.0\.\d+$/.test(s)) return "Enter a Hedera account like 0.0.123456";
171
+ },
172
+ });
173
+ if (p.isCancel(hederaAccount)) {
174
+ p.cancel("Setup cancelled");
175
+ process.exit(0);
176
+ }
177
+ hederaAccountId = String(hederaAccount).trim();
217
178
  }
218
- const hederaAccountId = String(hederaAccount).trim();
219
179
 
220
- const confirm = await p.confirm({
221
- message: `Join ${selected.model} hosting ${layersN} layers (${networkMode}) paying to ${hederaAccountId}?`,
222
- initialValue: true,
223
- });
224
- if (p.isCancel(confirm) || !confirm) {
225
- p.cancel("Setup cancelled");
226
- process.exit(0);
180
+ if (!nonInteractive) {
181
+ const confirm = await p.confirm({
182
+ message: `Join ${selected.model} hosting ${layersN} layers, payouts to ${hederaAccountId}?`,
183
+ initialValue: true,
184
+ });
185
+ if (p.isCancel(confirm) || !confirm) {
186
+ p.cancel("Setup cancelled");
187
+ process.exit(0);
188
+ }
189
+ } else {
190
+ p.log.info(`Joining ${selected.model} with ${layersN} layers…`);
227
191
  }
228
192
 
229
193
  spin.start("Preparing Python environment + Petals");
@@ -233,6 +197,7 @@ async function wizard(args) {
233
197
  spin.message(msg);
234
198
  },
235
199
  });
200
+ syncRuntimeFiles();
236
201
  } catch (err) {
237
202
  spin.stop("Install failed");
238
203
  p.cancel(err.message);
@@ -241,8 +206,28 @@ async function wizard(args) {
241
206
  const python = venvPython();
242
207
  spin.stop("Environment ready");
243
208
 
244
- const shardManagerUrl = `http://${localIp}:${SHARD_PORT}`;
245
- const petalsLogPath = path.join(HOME_DIR, "petals.log");
209
+ spin.start("Setting up Cloudflare Quick Tunnel…");
210
+ let tunnel;
211
+ try {
212
+ const cfBin = await ensureCloudflared({
213
+ onLog: (msg) => {
214
+ spin.message(msg);
215
+ },
216
+ });
217
+ tunnel = await startQuickTunnel({
218
+ port: SHARD_PORT,
219
+ binary: cfBin,
220
+ logPath: path.join(HOME_DIR, "cloudflared.log"),
221
+ });
222
+ } catch (err) {
223
+ spin.stop("Tunnel failed");
224
+ p.cancel(err.message);
225
+ process.exit(1);
226
+ }
227
+ spin.stop(`Public URL ${tunnel.url}`);
228
+
229
+ const localIp = await detectLocalIp();
230
+ const publicIp = (await detectPublicIp()) || localIp || "tunnel";
246
231
 
247
232
  spin.start("Requesting layer assignment from mother…");
248
233
  let assignment;
@@ -251,10 +236,11 @@ async function wizard(args) {
251
236
  model: selected.model,
252
237
  layers: layersN,
253
238
  public_ip: String(publicIp).trim(),
254
- shard_manager_url: shardManagerUrl,
239
+ shard_manager_url: tunnel.url,
255
240
  hedera_account_id: hederaAccountId,
256
241
  });
257
242
  } catch (err) {
243
+ tunnel.stop();
258
244
  spin.stop("Join rejected");
259
245
  const max = err.body?.detail?.max_layers;
260
246
  p.cancel(`${err.message}${max != null ? ` (max available: ${max})` : ""}`);
@@ -265,19 +251,21 @@ async function wizard(args) {
265
251
  const logPath = path.join(HOME_DIR, "shard_manager.log");
266
252
  spin.start("Starting local Petals server…");
267
253
  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
- }
254
+ MODEL_NAME: selected.model,
255
+ PUBLIC_IP: String(publicIp).trim(),
256
+ BLOCK_INDICES: assignment.block_indices,
257
+ INITIAL_PEERS: "",
258
+ NEW_SWARM: "1",
259
+ BLITZWING_HTTP_ONLY: "1",
260
+ PETALS_PORT: String(PETALS_PORT),
261
+ IDENTITY_PATH: path.join(HOME_DIR, "petals-identity"),
262
+ SHARD_AUTO_START: "1",
263
+ PETALS_USE_AUTO_RELAY: "0",
264
+ PETALS_SKIP_REACHABILITY_CHECK: "1",
265
+ BLITZWING_MOTHER_URL: selected.mother_url,
266
+ MOTHER_PUBLIC_SHARD_URL: selected.mother_url,
267
+ BLITZWING_HOST_ID: assignment.host_id,
268
+ };
281
269
  const { pid } = startShardManagerProcess({
282
270
  python,
283
271
  logPath,
@@ -287,6 +275,7 @@ async function wizard(args) {
287
275
  try {
288
276
  await waitForShardRunning({ timeoutMs: 600000, statusHost: "127.0.0.1" });
289
277
  } catch (err) {
278
+ tunnel.stop();
290
279
  spin.stop("Petals did not become ready");
291
280
  p.cancel(`${err.message}. See ${logPath}`);
292
281
  process.exit(1);
@@ -294,13 +283,12 @@ async function wizard(args) {
294
283
  spin.stop("Petals is serving your layers");
295
284
 
296
285
  spin.start("Finalizing handoff with mother…");
297
- const peerMultiaddr = extractPeerMultiaddrFromLog(petalsLogPath, fs);
298
286
  try {
299
287
  await readyHost(selected.mother_url, {
300
288
  host_id: assignment.host_id,
301
- peer_multiaddr: peerMultiaddr || undefined,
302
289
  });
303
290
  } catch (err) {
291
+ tunnel.stop();
304
292
  spin.stop("Handoff failed");
305
293
  p.cancel(err.message);
306
294
  process.exit(1);
@@ -314,9 +302,10 @@ async function wizard(args) {
314
302
  block_indices: assignment.block_indices,
315
303
  layers_hosted: assignment.layers_hosted,
316
304
  public_ip: String(publicIp).trim(),
317
- network_mode: networkMode,
318
- announce_maddrs: announceMaddrs || null,
319
- shard_manager_url: shardManagerUrl,
305
+ network_mode: "cloudflare",
306
+ shard_manager_url: tunnel.url,
307
+ tunnel_url: tunnel.url,
308
+ tunnel_pid: tunnel.pid,
320
309
  shard_pid: pid,
321
310
  discovery_url: args.discoveryUrl,
322
311
  hedera_account_id: hederaAccountId,
@@ -330,6 +319,7 @@ async function wizard(args) {
330
319
 
331
320
  p.outro(
332
321
  `${color.green("You are online.")} Hosting ${color.cyan(String(state.layers_hosted))} layers of ${color.cyan(state.model)} at ${state.block_indices}\n` +
322
+ `Tunnel: ${color.cyan(tunnel.url)}\n` +
333
323
  `Run ${color.bold("blitzwing status")} anytime, or ${color.bold("blitzwing leave")} to exit.`
334
324
  );
335
325
  }
@@ -346,10 +336,10 @@ async function showStatus() {
346
336
  console.log(` layers_hosted: ${state.layers_hosted}`);
347
337
  console.log(` block_indices: ${state.block_indices}`);
348
338
  console.log(` mother: ${state.mother_url}`);
349
- console.log(` public_ip: ${state.public_ip}`);
339
+ console.log(` tunnel: ${state.tunnel_url || state.shard_manager_url || "—"}`);
340
+ console.log(` hedera: ${state.hedera_account_id || "—"}`);
350
341
  try {
351
- const statusHost = "127.0.0.1";
352
- const res = await fetch(`http://${statusHost}:${SHARD_PORT}/status`);
342
+ const res = await fetch(`http://127.0.0.1:${SHARD_PORT}/status`);
353
343
  if (res.ok) {
354
344
  const st = await res.json();
355
345
  console.log(` petals_running: ${st.running}`);
@@ -376,11 +366,13 @@ async function doLeave() {
376
366
  p.log.warn(`Mother leave call failed: ${err.message}`);
377
367
  }
378
368
  try {
379
- const statusHost = "127.0.0.1";
380
- await fetch(`http://${statusHost}:${SHARD_PORT}/stop`, { method: "POST" });
369
+ await fetch(`http://127.0.0.1:${SHARD_PORT}/stop`, { method: "POST" });
381
370
  } catch {
382
371
  /* ignore */
383
372
  }
373
+ if (state.tunnel_pid) {
374
+ stopTunnelProcess(state.tunnel_pid);
375
+ }
384
376
  clearState();
385
377
  spin.stop("Left swarm");
386
378
  p.outro("Your layers were reclaimed by the mother (when reachable).");