pubblue 0.4.7 → 0.4.10
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/dist/index.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
getSocketPath,
|
|
4
|
+
ipcCall
|
|
5
|
+
} from "./chunk-HJ5LTUHS.js";
|
|
2
6
|
import {
|
|
3
7
|
TunnelApiClient,
|
|
4
8
|
TunnelApiError
|
|
5
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-7NFHPJ76.js";
|
|
6
10
|
import {
|
|
7
11
|
CHANNELS,
|
|
12
|
+
CONTROL_CHANNEL,
|
|
8
13
|
generateMessageId
|
|
9
14
|
} from "./chunk-MW35LBNH.js";
|
|
10
15
|
|
|
@@ -45,7 +50,7 @@ function loadConfig(homeDir) {
|
|
|
45
50
|
}
|
|
46
51
|
function saveConfig(config, homeDir) {
|
|
47
52
|
const configPath = getConfigPath(homeDir);
|
|
48
|
-
fs.writeFileSync(configPath, `${JSON.stringify(
|
|
53
|
+
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
|
|
49
54
|
`, {
|
|
50
55
|
mode: 384
|
|
51
56
|
});
|
|
@@ -58,10 +63,10 @@ function getConfig(homeDir) {
|
|
|
58
63
|
const envKey = process.env.PUBBLUE_API_KEY;
|
|
59
64
|
const envUrl = process.env.PUBBLUE_URL;
|
|
60
65
|
const baseUrl = envUrl || DEFAULT_BASE_URL;
|
|
66
|
+
const saved = loadConfig(homeDir);
|
|
61
67
|
if (envKey) {
|
|
62
|
-
return { apiKey: envKey, baseUrl };
|
|
68
|
+
return { apiKey: envKey, baseUrl, bridge: saved?.bridge };
|
|
63
69
|
}
|
|
64
|
-
const saved = loadConfig(homeDir);
|
|
65
70
|
if (!saved) {
|
|
66
71
|
throw new Error(
|
|
67
72
|
"Not configured. Run `pubblue configure` or set PUBBLUE_API_KEY environment variable."
|
|
@@ -69,62 +74,11 @@ function getConfig(homeDir) {
|
|
|
69
74
|
}
|
|
70
75
|
return {
|
|
71
76
|
apiKey: saved.apiKey,
|
|
72
|
-
baseUrl
|
|
77
|
+
baseUrl,
|
|
78
|
+
bridge: saved.bridge
|
|
73
79
|
};
|
|
74
80
|
}
|
|
75
81
|
|
|
76
|
-
// src/lib/tunnel-ipc.ts
|
|
77
|
-
import * as net from "net";
|
|
78
|
-
function getSocketPath(tunnelId) {
|
|
79
|
-
return `/tmp/pubblue-${tunnelId}.sock`;
|
|
80
|
-
}
|
|
81
|
-
async function ipcCall(socketPath, request) {
|
|
82
|
-
return new Promise((resolve3, reject) => {
|
|
83
|
-
let settled = false;
|
|
84
|
-
let timeoutId = null;
|
|
85
|
-
const finish = (fn) => {
|
|
86
|
-
if (settled) return;
|
|
87
|
-
settled = true;
|
|
88
|
-
if (timeoutId) clearTimeout(timeoutId);
|
|
89
|
-
fn();
|
|
90
|
-
};
|
|
91
|
-
const client = net.createConnection(socketPath, () => {
|
|
92
|
-
client.write(`${JSON.stringify(request)}
|
|
93
|
-
`);
|
|
94
|
-
});
|
|
95
|
-
let data = "";
|
|
96
|
-
client.on("data", (chunk) => {
|
|
97
|
-
data += chunk.toString();
|
|
98
|
-
const newlineIdx = data.indexOf("\n");
|
|
99
|
-
if (newlineIdx !== -1) {
|
|
100
|
-
const line = data.slice(0, newlineIdx);
|
|
101
|
-
client.end();
|
|
102
|
-
try {
|
|
103
|
-
finish(() => resolve3(JSON.parse(line)));
|
|
104
|
-
} catch {
|
|
105
|
-
finish(() => reject(new Error("Invalid response from daemon")));
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
});
|
|
109
|
-
client.on("error", (err) => {
|
|
110
|
-
if (err.code === "ECONNREFUSED" || err.code === "ENOENT") {
|
|
111
|
-
finish(() => reject(new Error("Daemon not running. Is the tunnel still active?")));
|
|
112
|
-
} else {
|
|
113
|
-
finish(() => reject(err));
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
client.on("end", () => {
|
|
117
|
-
if (!data.includes("\n")) {
|
|
118
|
-
finish(() => reject(new Error("Daemon closed connection unexpectedly")));
|
|
119
|
-
}
|
|
120
|
-
});
|
|
121
|
-
timeoutId = setTimeout(() => {
|
|
122
|
-
client.destroy();
|
|
123
|
-
finish(() => reject(new Error("Daemon request timed out")));
|
|
124
|
-
}, 1e4);
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
|
|
128
82
|
// src/commands/tunnel.ts
|
|
129
83
|
var TEXT_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
130
84
|
".txt",
|
|
@@ -190,10 +144,38 @@ function tunnelInfoPath(tunnelId) {
|
|
|
190
144
|
function tunnelLogPath(tunnelId) {
|
|
191
145
|
return path2.join(tunnelInfoDir(), `${tunnelId}.log`);
|
|
192
146
|
}
|
|
193
|
-
function
|
|
194
|
-
|
|
147
|
+
function bridgeInfoPath(tunnelId) {
|
|
148
|
+
return path2.join(tunnelInfoDir(), `${tunnelId}.bridge.json`);
|
|
149
|
+
}
|
|
150
|
+
function bridgeLogPath(tunnelId) {
|
|
151
|
+
return path2.join(tunnelInfoDir(), `${tunnelId}.bridge.log`);
|
|
152
|
+
}
|
|
153
|
+
function createApiClient(configOverride) {
|
|
154
|
+
const config = configOverride || getConfig();
|
|
195
155
|
return new TunnelApiClient(config.baseUrl, config.apiKey);
|
|
196
156
|
}
|
|
157
|
+
function buildBridgeProcessEnv(bridgeConfig) {
|
|
158
|
+
const env = { ...process.env };
|
|
159
|
+
if (!bridgeConfig) return env;
|
|
160
|
+
const setIfMissing = (key, value) => {
|
|
161
|
+
if (value === void 0 || value === null) return;
|
|
162
|
+
const current = env[key];
|
|
163
|
+
if (typeof current === "string" && current.length > 0) return;
|
|
164
|
+
env[key] = String(value);
|
|
165
|
+
};
|
|
166
|
+
setIfMissing("OPENCLAW_PATH", bridgeConfig.openclawPath);
|
|
167
|
+
setIfMissing("OPENCLAW_SESSION_ID", bridgeConfig.sessionId);
|
|
168
|
+
setIfMissing("OPENCLAW_THREAD_ID", bridgeConfig.threadId);
|
|
169
|
+
if (bridgeConfig.deliver !== void 0) {
|
|
170
|
+
setIfMissing("OPENCLAW_DELIVER", bridgeConfig.deliver ? "1" : "0");
|
|
171
|
+
}
|
|
172
|
+
setIfMissing("OPENCLAW_DELIVER_CHANNEL", bridgeConfig.deliverChannel);
|
|
173
|
+
setIfMissing("OPENCLAW_REPLY_TO", bridgeConfig.replyTo);
|
|
174
|
+
if (bridgeConfig.deliverTimeoutMs !== void 0) {
|
|
175
|
+
setIfMissing("OPENCLAW_DELIVER_TIMEOUT_MS", bridgeConfig.deliverTimeoutMs);
|
|
176
|
+
}
|
|
177
|
+
return env;
|
|
178
|
+
}
|
|
197
179
|
async function ensureNodeDatachannelAvailable() {
|
|
198
180
|
try {
|
|
199
181
|
await import("node-datachannel");
|
|
@@ -220,6 +202,41 @@ function isDaemonRunning(tunnelId) {
|
|
|
220
202
|
return false;
|
|
221
203
|
}
|
|
222
204
|
}
|
|
205
|
+
function readBridgeProcessInfo(tunnelId) {
|
|
206
|
+
const infoPath = bridgeInfoPath(tunnelId);
|
|
207
|
+
if (!fs2.existsSync(infoPath)) return null;
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(fs2.readFileSync(infoPath, "utf-8"));
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function isBridgeRunning(tunnelId) {
|
|
215
|
+
const infoPath = bridgeInfoPath(tunnelId);
|
|
216
|
+
if (!fs2.existsSync(infoPath)) return false;
|
|
217
|
+
try {
|
|
218
|
+
const info = JSON.parse(fs2.readFileSync(infoPath, "utf-8"));
|
|
219
|
+
process.kill(info.pid, 0);
|
|
220
|
+
return true;
|
|
221
|
+
} catch {
|
|
222
|
+
try {
|
|
223
|
+
fs2.unlinkSync(infoPath);
|
|
224
|
+
} catch {
|
|
225
|
+
}
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function stopBridgeProcess(tunnelId) {
|
|
230
|
+
const info = readBridgeProcessInfo(tunnelId);
|
|
231
|
+
if (!info || !Number.isFinite(info.pid)) return;
|
|
232
|
+
try {
|
|
233
|
+
process.kill(info.pid, "SIGTERM");
|
|
234
|
+
} catch {
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function buildBridgeForkStdio(logFd) {
|
|
238
|
+
return ["ignore", logFd, logFd, "ipc"];
|
|
239
|
+
}
|
|
223
240
|
function getFollowReadDelayMs(disconnected, consecutiveFailures) {
|
|
224
241
|
if (!disconnected) return 1e3;
|
|
225
242
|
return Math.min(5e3, 1e3 * 2 ** Math.min(consecutiveFailures, 3));
|
|
@@ -230,6 +247,28 @@ function resolveTunnelIdSelection(tunnelIdArg, tunnelOpt) {
|
|
|
230
247
|
function buildDaemonForkStdio(logFd) {
|
|
231
248
|
return ["ignore", logFd, logFd, "ipc"];
|
|
232
249
|
}
|
|
250
|
+
function parsePositiveIntegerOption(raw, optionName) {
|
|
251
|
+
const parsed = Number.parseInt(raw, 10);
|
|
252
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
253
|
+
throw new Error(`${optionName} must be a positive integer. Received: ${raw}`);
|
|
254
|
+
}
|
|
255
|
+
return parsed;
|
|
256
|
+
}
|
|
257
|
+
function parseBridgeMode(raw) {
|
|
258
|
+
const normalized = raw.trim().toLowerCase();
|
|
259
|
+
if (normalized === "openclaw" || normalized === "none") {
|
|
260
|
+
return normalized;
|
|
261
|
+
}
|
|
262
|
+
throw new Error(`--bridge must be one of: openclaw, none. Received: ${raw}`);
|
|
263
|
+
}
|
|
264
|
+
function messageContainsPong(payload) {
|
|
265
|
+
if (!payload || typeof payload !== "object") return false;
|
|
266
|
+
const message = payload.msg;
|
|
267
|
+
if (!message || typeof message !== "object") return false;
|
|
268
|
+
const type = message.type;
|
|
269
|
+
const data = message.data;
|
|
270
|
+
return type === "text" && typeof data === "string" && data.trim().toLowerCase() === "pong";
|
|
271
|
+
}
|
|
233
272
|
function getPublicTunnelUrl(tunnelId) {
|
|
234
273
|
const base = process.env.PUBBLUE_PUBLIC_URL || "https://pub.blue";
|
|
235
274
|
return `${base.replace(/\/$/, "")}/t/${tunnelId}`;
|
|
@@ -269,186 +308,263 @@ async function cleanupCreatedTunnelOnStartFailure(apiClient, target) {
|
|
|
269
308
|
}
|
|
270
309
|
function registerTunnelCommands(program2) {
|
|
271
310
|
const tunnel = program2.command("tunnel").description("P2P encrypted tunnel to browser");
|
|
272
|
-
tunnel.command("start").description("Start a tunnel daemon (reuses existing tunnel when possible)").option("--expires <duration>", "Auto-close after duration (e.g. 4h, 1d)", "24h").option("-t, --tunnel <tunnelId>", "Attach/start daemon for an existing tunnel").option("--new", "Always create a new tunnel (skip single-tunnel reuse)").option("--foreground", "Run in foreground (don't fork)").action(
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
311
|
+
tunnel.command("start").description("Start a tunnel daemon (reuses existing tunnel when possible)").option("--expires <duration>", "Auto-close after duration (e.g. 4h, 1d)", "24h").option("-t, --tunnel <tunnelId>", "Attach/start daemon for an existing tunnel").option("--new", "Always create a new tunnel (skip single-tunnel reuse)").option("--bridge <mode>", "Bridge mode: openclaw|none").option("--foreground", "Run in foreground (don't fork, no managed bridge)").action(
|
|
312
|
+
async (opts) => {
|
|
313
|
+
await ensureNodeDatachannelAvailable();
|
|
314
|
+
const runtimeConfig = getConfig();
|
|
315
|
+
const apiClient = createApiClient(runtimeConfig);
|
|
316
|
+
let target = null;
|
|
317
|
+
let bridgeMode;
|
|
277
318
|
try {
|
|
278
|
-
|
|
279
|
-
if (existing.status === "closed" || existing.expiresAt <= Date.now()) {
|
|
280
|
-
console.error(`Tunnel ${opts.tunnel} is closed or expired.`);
|
|
281
|
-
process.exit(1);
|
|
282
|
-
}
|
|
283
|
-
target = {
|
|
284
|
-
createdNew: false,
|
|
285
|
-
expiresAt: existing.expiresAt,
|
|
286
|
-
mode: "existing",
|
|
287
|
-
tunnelId: existing.tunnelId,
|
|
288
|
-
url: getPublicTunnelUrl(existing.tunnelId)
|
|
289
|
-
};
|
|
319
|
+
bridgeMode = parseBridgeMode(opts.bridge || runtimeConfig.bridge?.mode || "openclaw");
|
|
290
320
|
} catch (error) {
|
|
291
|
-
console.error(
|
|
321
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
292
322
|
process.exit(1);
|
|
293
323
|
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
324
|
+
const bridgeProcessEnv = buildBridgeProcessEnv(runtimeConfig.bridge);
|
|
325
|
+
if (opts.tunnel) {
|
|
326
|
+
try {
|
|
327
|
+
const existing = await apiClient.get(opts.tunnel);
|
|
328
|
+
if (existing.status === "closed" || existing.expiresAt <= Date.now()) {
|
|
329
|
+
console.error(`Tunnel ${opts.tunnel} is closed or expired.`);
|
|
330
|
+
process.exit(1);
|
|
331
|
+
}
|
|
300
332
|
target = {
|
|
301
333
|
createdNew: false,
|
|
302
|
-
expiresAt:
|
|
334
|
+
expiresAt: existing.expiresAt,
|
|
303
335
|
mode: "existing",
|
|
304
|
-
tunnelId:
|
|
305
|
-
url: getPublicTunnelUrl(
|
|
336
|
+
tunnelId: existing.tunnelId,
|
|
337
|
+
url: getPublicTunnelUrl(existing.tunnelId)
|
|
306
338
|
};
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
339
|
+
} catch (error) {
|
|
340
|
+
console.error(`Failed to use tunnel ${opts.tunnel}: ${formatApiError(error)}`);
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
} else if (!opts.new) {
|
|
344
|
+
try {
|
|
345
|
+
const listed = await apiClient.list();
|
|
346
|
+
const active = listed.filter((t) => t.status === "active" && t.expiresAt > Date.now()).sort((a, b) => b.createdAt - a.createdAt);
|
|
347
|
+
const reusable = pickReusableTunnel(listed);
|
|
348
|
+
if (reusable) {
|
|
349
|
+
target = {
|
|
350
|
+
createdNew: false,
|
|
351
|
+
expiresAt: reusable.expiresAt,
|
|
352
|
+
mode: "existing",
|
|
353
|
+
tunnelId: reusable.tunnelId,
|
|
354
|
+
url: getPublicTunnelUrl(reusable.tunnelId)
|
|
355
|
+
};
|
|
356
|
+
if (active.length > 1) {
|
|
357
|
+
console.error(
|
|
358
|
+
[
|
|
359
|
+
`Multiple active tunnels found: ${active.map((t) => t.tunnelId).join(", ")}`,
|
|
360
|
+
`Reusing most recent active tunnel ${reusable.tunnelId}.`,
|
|
361
|
+
"Use --tunnel <id> to choose explicitly or --new to force creation."
|
|
362
|
+
].join("\n")
|
|
363
|
+
);
|
|
364
|
+
} else {
|
|
365
|
+
console.error(
|
|
366
|
+
`Reusing existing active tunnel ${reusable.tunnelId}. Use --new to force creation.`
|
|
367
|
+
);
|
|
368
|
+
}
|
|
319
369
|
}
|
|
370
|
+
} catch (error) {
|
|
371
|
+
console.error(`Failed to list tunnels for reuse check: ${formatApiError(error)}`);
|
|
372
|
+
process.exit(1);
|
|
320
373
|
}
|
|
321
|
-
} catch (error) {
|
|
322
|
-
console.error(`Failed to list tunnels for reuse check: ${formatApiError(error)}`);
|
|
323
|
-
process.exit(1);
|
|
324
374
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
375
|
+
if (!target) {
|
|
376
|
+
try {
|
|
377
|
+
const created = await apiClient.create({
|
|
378
|
+
expiresIn: opts.expires
|
|
379
|
+
});
|
|
380
|
+
target = {
|
|
381
|
+
createdNew: true,
|
|
382
|
+
expiresAt: created.expiresAt,
|
|
383
|
+
mode: "created",
|
|
384
|
+
tunnelId: created.tunnelId,
|
|
385
|
+
url: created.url
|
|
386
|
+
};
|
|
387
|
+
} catch (error) {
|
|
388
|
+
console.error(`Failed to create tunnel: ${formatApiError(error)}`);
|
|
389
|
+
process.exit(1);
|
|
390
|
+
}
|
|
341
391
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
console.error("Failed to resolve tunnel target.");
|
|
345
|
-
process.exit(1);
|
|
346
|
-
}
|
|
347
|
-
const socketPath = getSocketPath(target.tunnelId);
|
|
348
|
-
const infoPath = tunnelInfoPath(target.tunnelId);
|
|
349
|
-
const logPath = tunnelLogPath(target.tunnelId);
|
|
350
|
-
if (opts.foreground) {
|
|
351
|
-
const { startDaemon } = await import("./tunnel-daemon-4LV6HLYN.js");
|
|
352
|
-
console.log(`Tunnel started: ${target.url}`);
|
|
353
|
-
console.log(`Tunnel ID: ${target.tunnelId}`);
|
|
354
|
-
console.log(`Expires: ${new Date(target.expiresAt).toISOString()}`);
|
|
355
|
-
if (target.mode === "existing") console.log("Mode: attached existing tunnel");
|
|
356
|
-
console.log("Running in foreground. Press Ctrl+C to stop.");
|
|
357
|
-
try {
|
|
358
|
-
await startDaemon({
|
|
359
|
-
tunnelId: target.tunnelId,
|
|
360
|
-
apiClient,
|
|
361
|
-
socketPath,
|
|
362
|
-
infoPath
|
|
363
|
-
});
|
|
364
|
-
} catch (error) {
|
|
365
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
366
|
-
console.error(`Daemon failed: ${message}`);
|
|
392
|
+
if (!target) {
|
|
393
|
+
console.error("Failed to resolve tunnel target.");
|
|
367
394
|
process.exit(1);
|
|
368
395
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
} catch (error) {
|
|
396
|
+
const socketPath = getSocketPath(target.tunnelId);
|
|
397
|
+
const infoPath = tunnelInfoPath(target.tunnelId);
|
|
398
|
+
const logPath = tunnelLogPath(target.tunnelId);
|
|
399
|
+
if (opts.foreground) {
|
|
400
|
+
if (bridgeMode !== "none") {
|
|
375
401
|
console.error(
|
|
376
|
-
|
|
402
|
+
"Foreground mode disables managed bridge process. Use background mode for --bridge openclaw."
|
|
377
403
|
);
|
|
378
|
-
console.error("Run `pubblue tunnel close <id>` and start again.");
|
|
379
|
-
process.exit(1);
|
|
380
404
|
}
|
|
405
|
+
const { startDaemon } = await import("./tunnel-daemon-4LV6HLYN.js");
|
|
381
406
|
console.log(`Tunnel started: ${target.url}`);
|
|
382
407
|
console.log(`Tunnel ID: ${target.tunnelId}`);
|
|
383
408
|
console.log(`Expires: ${new Date(target.expiresAt).toISOString()}`);
|
|
384
|
-
console.log("
|
|
385
|
-
console.log(
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
PUBBLUE_DAEMON_BASE_URL: config.baseUrl,
|
|
398
|
-
PUBBLUE_DAEMON_API_KEY: config.apiKey,
|
|
399
|
-
PUBBLUE_DAEMON_SOCKET: socketPath,
|
|
400
|
-
PUBBLUE_DAEMON_INFO: infoPath
|
|
409
|
+
if (target.mode === "existing") console.log("Mode: attached existing tunnel");
|
|
410
|
+
console.log("Running in foreground. Press Ctrl+C to stop.");
|
|
411
|
+
try {
|
|
412
|
+
await startDaemon({
|
|
413
|
+
tunnelId: target.tunnelId,
|
|
414
|
+
apiClient,
|
|
415
|
+
socketPath,
|
|
416
|
+
infoPath
|
|
417
|
+
});
|
|
418
|
+
} catch (error) {
|
|
419
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
420
|
+
console.error(`Daemon failed: ${message}`);
|
|
421
|
+
process.exit(1);
|
|
401
422
|
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
+
} else {
|
|
424
|
+
if (isDaemonRunning(target.tunnelId)) {
|
|
425
|
+
try {
|
|
426
|
+
const status = await ipcCall(socketPath, { method: "status", params: {} });
|
|
427
|
+
if (!status.ok) throw new Error(String(status.error || "status check failed"));
|
|
428
|
+
} catch (error) {
|
|
429
|
+
console.error(
|
|
430
|
+
`Daemon process exists but is not responding: ${error instanceof Error ? error.message : String(error)}`
|
|
431
|
+
);
|
|
432
|
+
console.error("Run `pubblue tunnel close <id>` and start again.");
|
|
433
|
+
process.exit(1);
|
|
434
|
+
}
|
|
435
|
+
if (bridgeMode !== "none") {
|
|
436
|
+
const bridgeReady = await ensureBridgeReady({
|
|
437
|
+
bridgeMode,
|
|
438
|
+
tunnelId: target.tunnelId,
|
|
439
|
+
socketPath,
|
|
440
|
+
bridgeProcessEnv,
|
|
441
|
+
timeoutMs: 8e3
|
|
442
|
+
});
|
|
443
|
+
if (!bridgeReady.ok) {
|
|
444
|
+
console.error(
|
|
445
|
+
`Bridge failed to start for running tunnel: ${bridgeReady.reason ?? "unknown reason"}`
|
|
446
|
+
);
|
|
447
|
+
const existingBridgeLog = bridgeLogPath(target.tunnelId);
|
|
448
|
+
if (fs2.existsSync(existingBridgeLog)) {
|
|
449
|
+
console.error(`Bridge log: ${existingBridgeLog}`);
|
|
450
|
+
const bridgeTail = readLogTail(existingBridgeLog);
|
|
451
|
+
if (bridgeTail) {
|
|
452
|
+
console.error("---- bridge log tail ----");
|
|
453
|
+
console.error(bridgeTail.trimEnd());
|
|
454
|
+
console.error("---- end bridge log tail ----");
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
process.exit(1);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
console.log(`Tunnel started: ${target.url}`);
|
|
461
|
+
console.log(`Tunnel ID: ${target.tunnelId}`);
|
|
462
|
+
console.log(`Expires: ${new Date(target.expiresAt).toISOString()}`);
|
|
463
|
+
console.log("Daemon already running for this tunnel.");
|
|
464
|
+
console.log(`Daemon log: ${logPath}`);
|
|
465
|
+
if (bridgeMode !== "none") {
|
|
466
|
+
console.log("Bridge mode: openclaw");
|
|
467
|
+
console.log(`Bridge log: ${bridgeLogPath(target.tunnelId)}`);
|
|
468
|
+
}
|
|
469
|
+
return;
|
|
423
470
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
471
|
+
const daemonScript = path2.join(import.meta.dirname, "tunnel-daemon-entry.js");
|
|
472
|
+
const config = getConfig();
|
|
473
|
+
const daemonLogFd = fs2.openSync(logPath, "a");
|
|
474
|
+
const child = fork(daemonScript, [], {
|
|
475
|
+
detached: true,
|
|
476
|
+
stdio: buildDaemonForkStdio(daemonLogFd),
|
|
477
|
+
env: {
|
|
478
|
+
...process.env,
|
|
479
|
+
PUBBLUE_DAEMON_TUNNEL_ID: target.tunnelId,
|
|
480
|
+
PUBBLUE_DAEMON_BASE_URL: config.baseUrl,
|
|
481
|
+
PUBBLUE_DAEMON_API_KEY: config.apiKey,
|
|
482
|
+
PUBBLUE_DAEMON_SOCKET: socketPath,
|
|
483
|
+
PUBBLUE_DAEMON_INFO: infoPath
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
fs2.closeSync(daemonLogFd);
|
|
487
|
+
if (child.connected) {
|
|
488
|
+
child.disconnect();
|
|
489
|
+
}
|
|
490
|
+
child.unref();
|
|
491
|
+
console.log(`Starting daemon for tunnel ${target.tunnelId}...`);
|
|
492
|
+
const ready = await waitForDaemonReady({
|
|
493
|
+
child,
|
|
494
|
+
infoPath,
|
|
495
|
+
socketPath,
|
|
496
|
+
timeoutMs: 8e3
|
|
497
|
+
});
|
|
498
|
+
if (!ready.ok) {
|
|
499
|
+
console.error(`Daemon failed to start: ${ready.reason ?? "unknown reason"}`);
|
|
500
|
+
console.error(`Daemon log: ${logPath}`);
|
|
501
|
+
const tail = readLogTail(logPath);
|
|
502
|
+
if (tail) {
|
|
503
|
+
console.error("---- daemon log tail ----");
|
|
504
|
+
console.error(tail.trimEnd());
|
|
505
|
+
console.error("---- end daemon log tail ----");
|
|
506
|
+
}
|
|
507
|
+
await cleanupCreatedTunnelOnStartFailure(apiClient, target);
|
|
508
|
+
process.exit(1);
|
|
509
|
+
}
|
|
510
|
+
const offerReady = await waitForAgentOffer({
|
|
511
|
+
apiClient,
|
|
512
|
+
tunnelId: target.tunnelId,
|
|
513
|
+
timeoutMs: 5e3
|
|
514
|
+
});
|
|
515
|
+
if (!offerReady.ok) {
|
|
516
|
+
console.error(`Daemon started but signaling is not ready: ${offerReady.reason}`);
|
|
517
|
+
console.error(`Daemon log: ${logPath}`);
|
|
518
|
+
const tail = readLogTail(logPath);
|
|
519
|
+
if (tail) {
|
|
520
|
+
console.error("---- daemon log tail ----");
|
|
521
|
+
console.error(tail.trimEnd());
|
|
522
|
+
console.error("---- end daemon log tail ----");
|
|
523
|
+
}
|
|
524
|
+
await cleanupCreatedTunnelOnStartFailure(apiClient, target);
|
|
525
|
+
process.exit(1);
|
|
526
|
+
}
|
|
527
|
+
if (bridgeMode !== "none") {
|
|
528
|
+
const bridgeReady = await ensureBridgeReady({
|
|
529
|
+
bridgeMode,
|
|
530
|
+
tunnelId: target.tunnelId,
|
|
531
|
+
socketPath,
|
|
532
|
+
bridgeProcessEnv,
|
|
533
|
+
timeoutMs: 8e3
|
|
534
|
+
});
|
|
535
|
+
if (!bridgeReady.ok) {
|
|
536
|
+
console.error(`Bridge failed to start: ${bridgeReady.reason ?? "unknown reason"}`);
|
|
537
|
+
const bridgeLog = bridgeLogPath(target.tunnelId);
|
|
538
|
+
if (fs2.existsSync(bridgeLog)) {
|
|
539
|
+
console.error(`Bridge log: ${bridgeLog}`);
|
|
540
|
+
const bridgeTail = readLogTail(bridgeLog);
|
|
541
|
+
if (bridgeTail) {
|
|
542
|
+
console.error("---- bridge log tail ----");
|
|
543
|
+
console.error(bridgeTail.trimEnd());
|
|
544
|
+
console.error("---- end bridge log tail ----");
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
await ipcCall(socketPath, { method: "close", params: {} });
|
|
549
|
+
} catch {
|
|
550
|
+
}
|
|
551
|
+
await cleanupCreatedTunnelOnStartFailure(apiClient, target);
|
|
552
|
+
process.exit(1);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
console.log(`Tunnel started: ${target.url}`);
|
|
556
|
+
console.log(`Tunnel ID: ${target.tunnelId}`);
|
|
557
|
+
console.log(`Expires: ${new Date(target.expiresAt).toISOString()}`);
|
|
558
|
+
if (target.mode === "existing") console.log("Mode: attached existing tunnel");
|
|
559
|
+
console.log("Daemon health: OK");
|
|
560
|
+
console.log(`Daemon log: ${logPath}`);
|
|
561
|
+
if (bridgeMode !== "none") {
|
|
562
|
+
console.log("Bridge mode: openclaw");
|
|
563
|
+
console.log(`Bridge log: ${bridgeLogPath(target.tunnelId)}`);
|
|
440
564
|
}
|
|
441
|
-
await cleanupCreatedTunnelOnStartFailure(apiClient, target);
|
|
442
|
-
process.exit(1);
|
|
443
565
|
}
|
|
444
|
-
console.log(`Tunnel started: ${target.url}`);
|
|
445
|
-
console.log(`Tunnel ID: ${target.tunnelId}`);
|
|
446
|
-
console.log(`Expires: ${new Date(target.expiresAt).toISOString()}`);
|
|
447
|
-
if (target.mode === "existing") console.log("Mode: attached existing tunnel");
|
|
448
|
-
console.log("Daemon health: OK");
|
|
449
|
-
console.log(`Daemon log: ${logPath}`);
|
|
450
566
|
}
|
|
451
|
-
|
|
567
|
+
);
|
|
452
568
|
tunnel.command("write").description("Write data to a channel").argument("[message]", "Text message (or use --file)").option("-t, --tunnel <tunnelId>", "Tunnel ID (auto-detected if one active)").option("-c, --channel <channel>", "Channel name", "chat").option("-f, --file <file>", "Read content from file").action(
|
|
453
569
|
async (messageArg, opts) => {
|
|
454
570
|
let msg;
|
|
@@ -586,7 +702,148 @@ function registerTunnelCommands(program2) {
|
|
|
586
702
|
if (fs2.existsSync(logPath)) {
|
|
587
703
|
console.log(` Log: ${logPath}`);
|
|
588
704
|
}
|
|
705
|
+
const bridgeInfo = readBridgeProcessInfo(tunnelId);
|
|
706
|
+
if (bridgeInfo) {
|
|
707
|
+
const bridgeRunning = isBridgeRunning(tunnelId);
|
|
708
|
+
const bridgeState = bridgeInfo.status || (bridgeRunning ? "running" : "stopped");
|
|
709
|
+
console.log(` Bridge: ${bridgeInfo.mode} (${bridgeState})`);
|
|
710
|
+
if (bridgeInfo.sessionId) {
|
|
711
|
+
console.log(` Bridge session: ${bridgeInfo.sessionId}`);
|
|
712
|
+
}
|
|
713
|
+
if (!bridgeRunning && bridgeInfo.lastError) {
|
|
714
|
+
console.log(` Bridge error: ${bridgeInfo.lastError}`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
const bridgeLog = bridgeLogPath(tunnelId);
|
|
718
|
+
if (fs2.existsSync(bridgeLog)) {
|
|
719
|
+
console.log(` Bridge log: ${bridgeLog}`);
|
|
720
|
+
}
|
|
589
721
|
});
|
|
722
|
+
tunnel.command("doctor").description("Run strict end-to-end tunnel checks (daemon, channels, chat/canvas ping)").option("-t, --tunnel <tunnelId>", "Tunnel ID (auto-detected if one active)").option("--timeout <seconds>", "Timeout for pong wait and repeated reads", "30").option("--wait-pong", "Wait for user to reply with exact text 'pong' on chat channel").option("--skip-chat", "Skip chat ping check").option("--skip-canvas", "Skip canvas ping check").action(
|
|
723
|
+
async (opts) => {
|
|
724
|
+
const timeoutSeconds = parsePositiveIntegerOption(opts.timeout, "--timeout");
|
|
725
|
+
const timeoutMs = timeoutSeconds * 1e3;
|
|
726
|
+
const tunnelId = opts.tunnel || await resolveActiveTunnel();
|
|
727
|
+
const socketPath = getSocketPath(tunnelId);
|
|
728
|
+
const apiClient = createApiClient();
|
|
729
|
+
const fail = (message) => {
|
|
730
|
+
console.error(`Doctor failed: ${message}`);
|
|
731
|
+
process.exit(1);
|
|
732
|
+
};
|
|
733
|
+
console.log(`Doctor tunnel: ${tunnelId}`);
|
|
734
|
+
let statusResponse = null;
|
|
735
|
+
try {
|
|
736
|
+
statusResponse = await ipcCall(socketPath, {
|
|
737
|
+
method: "status",
|
|
738
|
+
params: {}
|
|
739
|
+
});
|
|
740
|
+
} catch (error) {
|
|
741
|
+
fail(
|
|
742
|
+
`daemon is unreachable (${error instanceof Error ? error.message : String(error)}).`
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
if (!statusResponse) {
|
|
746
|
+
fail("daemon status returned no response.");
|
|
747
|
+
}
|
|
748
|
+
const daemonStatus = statusResponse;
|
|
749
|
+
if (!daemonStatus.ok) {
|
|
750
|
+
fail(`daemon returned non-ok status: ${String(daemonStatus.error || "unknown error")}`);
|
|
751
|
+
}
|
|
752
|
+
if (!daemonStatus.connected) {
|
|
753
|
+
fail("daemon is running but browser is not connected.");
|
|
754
|
+
}
|
|
755
|
+
const channelNames = Array.isArray(daemonStatus.channels) ? daemonStatus.channels.map((entry) => String(entry)) : [];
|
|
756
|
+
for (const required of [CONTROL_CHANNEL, CHANNELS.CHAT, CHANNELS.CANVAS]) {
|
|
757
|
+
if (!channelNames.includes(required)) {
|
|
758
|
+
fail(`required channel is missing: ${required}`);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
console.log("Daemon/channel check: OK");
|
|
762
|
+
let tunnelInfo = null;
|
|
763
|
+
try {
|
|
764
|
+
tunnelInfo = await apiClient.get(tunnelId);
|
|
765
|
+
} catch (error) {
|
|
766
|
+
fail(`failed to fetch tunnel info from API: ${formatApiError(error)}`);
|
|
767
|
+
}
|
|
768
|
+
if (!tunnelInfo) {
|
|
769
|
+
fail("API returned no tunnel payload.");
|
|
770
|
+
}
|
|
771
|
+
const apiTunnel = tunnelInfo;
|
|
772
|
+
if (apiTunnel.status !== "active") {
|
|
773
|
+
fail(`API reports tunnel is not active (status: ${apiTunnel.status})`);
|
|
774
|
+
}
|
|
775
|
+
if (apiTunnel.expiresAt <= Date.now()) {
|
|
776
|
+
fail("API reports tunnel is expired.");
|
|
777
|
+
}
|
|
778
|
+
if (!apiTunnel.hasConnection) {
|
|
779
|
+
fail("API reports no browser connection.");
|
|
780
|
+
}
|
|
781
|
+
if (typeof apiTunnel.agentOffer !== "string" || apiTunnel.agentOffer.length === 0) {
|
|
782
|
+
fail("agent offer was not published.");
|
|
783
|
+
}
|
|
784
|
+
console.log("API/signaling check: OK");
|
|
785
|
+
if (!opts.skipChat) {
|
|
786
|
+
const pingText = "This is a ping test. Reply with 'pong'.";
|
|
787
|
+
const pingMsg = {
|
|
788
|
+
id: generateMessageId(),
|
|
789
|
+
type: "text",
|
|
790
|
+
data: pingText
|
|
791
|
+
};
|
|
792
|
+
const writeResponse = await ipcCall(socketPath, {
|
|
793
|
+
method: "write",
|
|
794
|
+
params: { channel: CHANNELS.CHAT, msg: pingMsg }
|
|
795
|
+
});
|
|
796
|
+
if (!writeResponse.ok) {
|
|
797
|
+
fail(`chat ping failed: ${String(writeResponse.error || "unknown write error")}`);
|
|
798
|
+
}
|
|
799
|
+
console.log("Chat ping write ACK: OK");
|
|
800
|
+
if (opts.waitPong) {
|
|
801
|
+
const startedAt = Date.now();
|
|
802
|
+
let receivedPong = false;
|
|
803
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
804
|
+
const readResponse = await ipcCall(socketPath, {
|
|
805
|
+
method: "read",
|
|
806
|
+
params: { channel: CHANNELS.CHAT }
|
|
807
|
+
});
|
|
808
|
+
if (!readResponse.ok) {
|
|
809
|
+
fail(
|
|
810
|
+
`chat read failed while waiting for pong: ${String(readResponse.error || "unknown read error")}`
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
const messages = Array.isArray(readResponse.messages) ? readResponse.messages : [];
|
|
814
|
+
if (messages.some((entry) => messageContainsPong(entry))) {
|
|
815
|
+
receivedPong = true;
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
await new Promise((resolve3) => setTimeout(resolve3, 1e3));
|
|
819
|
+
}
|
|
820
|
+
if (!receivedPong) {
|
|
821
|
+
fail(
|
|
822
|
+
`timed out after ${timeoutSeconds}s waiting for exact 'pong' reply on chat channel.`
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
console.log("Chat pong roundtrip: OK");
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
if (!opts.skipCanvas) {
|
|
829
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
830
|
+
const canvasMsg = {
|
|
831
|
+
id: generateMessageId(),
|
|
832
|
+
type: "html",
|
|
833
|
+
data: `<!doctype html><html><body style="margin:0;padding:24px;font-family:system-ui;background:#111;color:#f5f5f5">Canvas ping OK<br><small>${stamp}</small></body></html>`
|
|
834
|
+
};
|
|
835
|
+
const canvasResponse = await ipcCall(socketPath, {
|
|
836
|
+
method: "write",
|
|
837
|
+
params: { channel: CHANNELS.CANVAS, msg: canvasMsg }
|
|
838
|
+
});
|
|
839
|
+
if (!canvasResponse.ok) {
|
|
840
|
+
fail(`canvas ping failed: ${String(canvasResponse.error || "unknown write error")}`);
|
|
841
|
+
}
|
|
842
|
+
console.log("Canvas ping write ACK: OK");
|
|
843
|
+
}
|
|
844
|
+
console.log("Tunnel doctor: PASS");
|
|
845
|
+
}
|
|
846
|
+
);
|
|
590
847
|
tunnel.command("list").description("List active tunnels").action(async () => {
|
|
591
848
|
const apiClient = createApiClient();
|
|
592
849
|
const tunnels = await apiClient.list();
|
|
@@ -597,11 +854,18 @@ function registerTunnelCommands(program2) {
|
|
|
597
854
|
for (const t of tunnels) {
|
|
598
855
|
const age = Math.floor((Date.now() - t.createdAt) / 6e4);
|
|
599
856
|
const running = isDaemonRunning(t.tunnelId) ? "running" : "no daemon";
|
|
857
|
+
const bridgeInfo = readBridgeProcessInfo(t.tunnelId);
|
|
858
|
+
const bridge = bridgeInfo ? isBridgeRunning(t.tunnelId) ? `${bridgeInfo.mode}:running` : `${bridgeInfo.mode}:stopped` : "none";
|
|
600
859
|
const conn = t.hasConnection ? "connected" : "waiting";
|
|
601
|
-
console.log(` ${t.tunnelId} ${conn} ${running} ${age}m ago`);
|
|
860
|
+
console.log(` ${t.tunnelId} ${conn} ${running} bridge=${bridge} ${age}m ago`);
|
|
602
861
|
}
|
|
603
862
|
});
|
|
604
863
|
tunnel.command("close").description("Close a tunnel and stop its daemon").argument("<tunnelId>", "Tunnel ID").action(async (tunnelId) => {
|
|
864
|
+
stopBridgeProcess(tunnelId);
|
|
865
|
+
try {
|
|
866
|
+
fs2.unlinkSync(bridgeInfoPath(tunnelId));
|
|
867
|
+
} catch {
|
|
868
|
+
}
|
|
605
869
|
const socketPath = getSocketPath(tunnelId);
|
|
606
870
|
try {
|
|
607
871
|
await ipcCall(socketPath, { method: "close", params: {} });
|
|
@@ -622,7 +886,7 @@ function registerTunnelCommands(program2) {
|
|
|
622
886
|
}
|
|
623
887
|
async function resolveActiveTunnel() {
|
|
624
888
|
const dir = tunnelInfoDir();
|
|
625
|
-
const files = fs2.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
889
|
+
const files = fs2.readdirSync(dir).filter((f) => f.endsWith(".json") && !f.endsWith(".bridge.json"));
|
|
626
890
|
const active = [];
|
|
627
891
|
for (const f of files) {
|
|
628
892
|
const tunnelId = f.replace(".json", "");
|
|
@@ -695,6 +959,94 @@ async function waitForAgentOffer(params) {
|
|
|
695
959
|
reason: lastError ? `agent offer was not published in time (last API error: ${lastError})` : "agent offer was not published in time"
|
|
696
960
|
};
|
|
697
961
|
}
|
|
962
|
+
async function ensureBridgeReady(params) {
|
|
963
|
+
if (params.bridgeMode === "none") {
|
|
964
|
+
return { ok: true };
|
|
965
|
+
}
|
|
966
|
+
const infoPath = bridgeInfoPath(params.tunnelId);
|
|
967
|
+
if (isBridgeRunning(params.tunnelId)) {
|
|
968
|
+
return waitForBridgeReady({
|
|
969
|
+
infoPath,
|
|
970
|
+
tunnelId: params.tunnelId,
|
|
971
|
+
timeoutMs: params.timeoutMs
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
const bridgeScript = path2.join(import.meta.dirname, "tunnel-bridge-entry.js");
|
|
975
|
+
const logPath = bridgeLogPath(params.tunnelId);
|
|
976
|
+
const logFd = fs2.openSync(logPath, "a");
|
|
977
|
+
const child = fork(bridgeScript, [], {
|
|
978
|
+
detached: true,
|
|
979
|
+
stdio: buildBridgeForkStdio(logFd),
|
|
980
|
+
env: {
|
|
981
|
+
...params.bridgeProcessEnv,
|
|
982
|
+
PUBBLUE_BRIDGE_MODE: params.bridgeMode,
|
|
983
|
+
PUBBLUE_BRIDGE_TUNNEL_ID: params.tunnelId,
|
|
984
|
+
PUBBLUE_BRIDGE_SOCKET: params.socketPath,
|
|
985
|
+
PUBBLUE_BRIDGE_INFO: infoPath
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
fs2.closeSync(logFd);
|
|
989
|
+
if (child.connected) {
|
|
990
|
+
child.disconnect();
|
|
991
|
+
}
|
|
992
|
+
child.unref();
|
|
993
|
+
return waitForBridgeReady({
|
|
994
|
+
child,
|
|
995
|
+
infoPath,
|
|
996
|
+
tunnelId: params.tunnelId,
|
|
997
|
+
timeoutMs: params.timeoutMs
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
function waitForBridgeReady({
|
|
1001
|
+
child,
|
|
1002
|
+
infoPath,
|
|
1003
|
+
tunnelId,
|
|
1004
|
+
timeoutMs
|
|
1005
|
+
}) {
|
|
1006
|
+
return new Promise((resolve3) => {
|
|
1007
|
+
let settled = false;
|
|
1008
|
+
let lastState;
|
|
1009
|
+
let lastError;
|
|
1010
|
+
const done = (result) => {
|
|
1011
|
+
if (settled) return;
|
|
1012
|
+
settled = true;
|
|
1013
|
+
clearInterval(poll);
|
|
1014
|
+
clearTimeout(timeout);
|
|
1015
|
+
if (child) {
|
|
1016
|
+
child.off("exit", onExit);
|
|
1017
|
+
}
|
|
1018
|
+
resolve3(result);
|
|
1019
|
+
};
|
|
1020
|
+
const onExit = (code, signal) => {
|
|
1021
|
+
const suffix = signal ? ` (signal ${signal})` : "";
|
|
1022
|
+
done({ ok: false, reason: `bridge exited with code ${code ?? 0}${suffix}` });
|
|
1023
|
+
};
|
|
1024
|
+
if (child) {
|
|
1025
|
+
child.on("exit", onExit);
|
|
1026
|
+
}
|
|
1027
|
+
const poll = setInterval(() => {
|
|
1028
|
+
if (!fs2.existsSync(infoPath)) return;
|
|
1029
|
+
const info = readBridgeProcessInfo(tunnelId);
|
|
1030
|
+
if (!info) return;
|
|
1031
|
+
lastState = info.status;
|
|
1032
|
+
lastError = info.lastError;
|
|
1033
|
+
if (info.status === "ready" && isBridgeRunning(tunnelId)) {
|
|
1034
|
+
done({ ok: true });
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
if (info.status === "error") {
|
|
1038
|
+
done({
|
|
1039
|
+
ok: false,
|
|
1040
|
+
reason: info.lastError ? `bridge reported startup error: ${info.lastError}` : "bridge reported startup error"
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
}, 120);
|
|
1044
|
+
const timeout = setTimeout(() => {
|
|
1045
|
+
const reason = lastError && lastError.length > 0 ? `timed out after ${timeoutMs}ms waiting for bridge readiness (last error: ${lastError})` : `timed out after ${timeoutMs}ms waiting for bridge readiness (state: ${lastState || "unknown"})`;
|
|
1046
|
+
done({ ok: false, reason });
|
|
1047
|
+
}, timeoutMs);
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
698
1050
|
|
|
699
1051
|
// src/lib/api.ts
|
|
700
1052
|
var PubApiClient = class {
|
|
@@ -808,6 +1160,143 @@ async function resolveConfigureApiKey(opts) {
|
|
|
808
1160
|
}
|
|
809
1161
|
return readApiKeyFromPrompt();
|
|
810
1162
|
}
|
|
1163
|
+
function collectValues(value, previous) {
|
|
1164
|
+
previous.push(value);
|
|
1165
|
+
return previous;
|
|
1166
|
+
}
|
|
1167
|
+
function parseSetInput(raw) {
|
|
1168
|
+
const sepIndex = raw.indexOf("=");
|
|
1169
|
+
if (sepIndex <= 0 || sepIndex === raw.length - 1) {
|
|
1170
|
+
throw new Error(`Invalid --set entry "${raw}". Use key=value.`);
|
|
1171
|
+
}
|
|
1172
|
+
return {
|
|
1173
|
+
key: raw.slice(0, sepIndex).trim(),
|
|
1174
|
+
value: raw.slice(sepIndex + 1).trim()
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
function parseBooleanValue(raw, key) {
|
|
1178
|
+
const normalized = raw.trim().toLowerCase();
|
|
1179
|
+
if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on")
|
|
1180
|
+
return true;
|
|
1181
|
+
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off")
|
|
1182
|
+
return false;
|
|
1183
|
+
throw new Error(`Invalid boolean value for ${key}: ${raw}`);
|
|
1184
|
+
}
|
|
1185
|
+
function parseBridgeModeValue(raw) {
|
|
1186
|
+
const normalized = raw.trim().toLowerCase();
|
|
1187
|
+
if (normalized === "openclaw" || normalized === "none") return normalized;
|
|
1188
|
+
throw new Error(`Invalid bridge mode: ${raw}. Use openclaw or none.`);
|
|
1189
|
+
}
|
|
1190
|
+
function parsePositiveInteger(raw, key) {
|
|
1191
|
+
const parsed = Number.parseInt(raw, 10);
|
|
1192
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
1193
|
+
throw new Error(`${key} must be a positive integer. Received: ${raw}`);
|
|
1194
|
+
}
|
|
1195
|
+
return parsed;
|
|
1196
|
+
}
|
|
1197
|
+
function applyBridgeSet(bridge, key, value) {
|
|
1198
|
+
switch (key) {
|
|
1199
|
+
case "bridge.mode":
|
|
1200
|
+
bridge.mode = parseBridgeModeValue(value);
|
|
1201
|
+
return;
|
|
1202
|
+
case "openclaw.path":
|
|
1203
|
+
bridge.openclawPath = value;
|
|
1204
|
+
return;
|
|
1205
|
+
case "openclaw.sessionId":
|
|
1206
|
+
bridge.sessionId = value;
|
|
1207
|
+
return;
|
|
1208
|
+
case "openclaw.threadId":
|
|
1209
|
+
bridge.threadId = value;
|
|
1210
|
+
return;
|
|
1211
|
+
case "openclaw.deliver":
|
|
1212
|
+
bridge.deliver = parseBooleanValue(value, key);
|
|
1213
|
+
return;
|
|
1214
|
+
case "openclaw.deliverChannel":
|
|
1215
|
+
bridge.deliverChannel = value;
|
|
1216
|
+
return;
|
|
1217
|
+
case "openclaw.replyTo":
|
|
1218
|
+
bridge.replyTo = value;
|
|
1219
|
+
return;
|
|
1220
|
+
case "openclaw.deliverTimeoutMs":
|
|
1221
|
+
bridge.deliverTimeoutMs = parsePositiveInteger(value, key);
|
|
1222
|
+
return;
|
|
1223
|
+
default:
|
|
1224
|
+
throw new Error(
|
|
1225
|
+
[
|
|
1226
|
+
`Unknown config key: ${key}`,
|
|
1227
|
+
"Supported keys:",
|
|
1228
|
+
" bridge.mode",
|
|
1229
|
+
" openclaw.path",
|
|
1230
|
+
" openclaw.sessionId",
|
|
1231
|
+
" openclaw.threadId",
|
|
1232
|
+
" openclaw.deliver",
|
|
1233
|
+
" openclaw.deliverChannel",
|
|
1234
|
+
" openclaw.replyTo",
|
|
1235
|
+
" openclaw.deliverTimeoutMs"
|
|
1236
|
+
].join("\n")
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
function applyBridgeUnset(bridge, key) {
|
|
1241
|
+
switch (key) {
|
|
1242
|
+
case "bridge.mode":
|
|
1243
|
+
delete bridge.mode;
|
|
1244
|
+
return;
|
|
1245
|
+
case "openclaw.path":
|
|
1246
|
+
delete bridge.openclawPath;
|
|
1247
|
+
return;
|
|
1248
|
+
case "openclaw.sessionId":
|
|
1249
|
+
delete bridge.sessionId;
|
|
1250
|
+
return;
|
|
1251
|
+
case "openclaw.threadId":
|
|
1252
|
+
delete bridge.threadId;
|
|
1253
|
+
return;
|
|
1254
|
+
case "openclaw.deliver":
|
|
1255
|
+
delete bridge.deliver;
|
|
1256
|
+
return;
|
|
1257
|
+
case "openclaw.deliverChannel":
|
|
1258
|
+
delete bridge.deliverChannel;
|
|
1259
|
+
return;
|
|
1260
|
+
case "openclaw.replyTo":
|
|
1261
|
+
delete bridge.replyTo;
|
|
1262
|
+
return;
|
|
1263
|
+
case "openclaw.deliverTimeoutMs":
|
|
1264
|
+
delete bridge.deliverTimeoutMs;
|
|
1265
|
+
return;
|
|
1266
|
+
default:
|
|
1267
|
+
throw new Error(`Unknown config key for --unset: ${key}`);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
function hasBridgeValues(bridge) {
|
|
1271
|
+
return Object.values(bridge).some((value) => value !== void 0);
|
|
1272
|
+
}
|
|
1273
|
+
function maskApiKey(apiKey) {
|
|
1274
|
+
if (apiKey.length <= 8) return "********";
|
|
1275
|
+
return `${apiKey.slice(0, 4)}...${apiKey.slice(-4)}`;
|
|
1276
|
+
}
|
|
1277
|
+
function printConfigSummary(saved) {
|
|
1278
|
+
if (!saved) {
|
|
1279
|
+
console.log("Saved config: none");
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
console.log("Saved config:");
|
|
1283
|
+
console.log(` apiKey: ${maskApiKey(saved.apiKey)}`);
|
|
1284
|
+
if (!saved.bridge || !hasBridgeValues(saved.bridge)) {
|
|
1285
|
+
console.log(" bridge: none");
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
console.log(` bridge.mode: ${saved.bridge.mode ?? "(unset)"}`);
|
|
1289
|
+
if (saved.bridge.openclawPath) console.log(` openclaw.path: ${saved.bridge.openclawPath}`);
|
|
1290
|
+
if (saved.bridge.sessionId) console.log(` openclaw.sessionId: ${saved.bridge.sessionId}`);
|
|
1291
|
+
if (saved.bridge.threadId) console.log(` openclaw.threadId: ${saved.bridge.threadId}`);
|
|
1292
|
+
if (saved.bridge.deliver !== void 0)
|
|
1293
|
+
console.log(` openclaw.deliver: ${saved.bridge.deliver ? "true" : "false"}`);
|
|
1294
|
+
if (saved.bridge.deliverChannel)
|
|
1295
|
+
console.log(` openclaw.deliverChannel: ${saved.bridge.deliverChannel}`);
|
|
1296
|
+
if (saved.bridge.replyTo) console.log(` openclaw.replyTo: ${saved.bridge.replyTo}`);
|
|
1297
|
+
if (saved.bridge.deliverTimeoutMs !== void 0)
|
|
1298
|
+
console.log(` openclaw.deliverTimeoutMs: ${saved.bridge.deliverTimeoutMs}`);
|
|
1299
|
+
}
|
|
811
1300
|
function resolveVisibilityFlags(opts) {
|
|
812
1301
|
if (opts.public && opts.private) {
|
|
813
1302
|
throw new Error(`Use only one of --public or --private for ${opts.commandName}.`);
|
|
@@ -827,18 +1316,62 @@ function readFile(filePath) {
|
|
|
827
1316
|
basename: path3.basename(resolved)
|
|
828
1317
|
};
|
|
829
1318
|
}
|
|
830
|
-
program.name("pubblue").description("Publish static content and get shareable URLs").version("0.4.
|
|
831
|
-
program.command("configure").description("Configure the CLI with your API key").option("--api-key <key>", "Your API key (less secure: appears in shell history)").option("--api-key-stdin", "Read API key from stdin").
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1319
|
+
program.name("pubblue").description("Publish static content and get shareable URLs").version("0.4.10");
|
|
1320
|
+
program.command("configure").description("Configure the CLI with your API key").option("--api-key <key>", "Your API key (less secure: appears in shell history)").option("--api-key-stdin", "Read API key from stdin").option(
|
|
1321
|
+
"--set <key=value>",
|
|
1322
|
+
"Set advanced config (repeatable). Example: --set openclaw.sessionId=<id>",
|
|
1323
|
+
collectValues,
|
|
1324
|
+
[]
|
|
1325
|
+
).option("--unset <key>", "Unset advanced config key (repeatable)", collectValues, []).option("--show", "Show saved configuration").action(
|
|
1326
|
+
async (opts) => {
|
|
1327
|
+
try {
|
|
1328
|
+
const saved = loadConfig();
|
|
1329
|
+
const hasApiUpdate = Boolean(opts.apiKey || opts.apiKeyStdin);
|
|
1330
|
+
const hasSet = opts.set.length > 0;
|
|
1331
|
+
const hasUnset = opts.unset.length > 0;
|
|
1332
|
+
const hasMutation = hasApiUpdate || hasSet || hasUnset;
|
|
1333
|
+
if (!hasMutation && opts.show) {
|
|
1334
|
+
printConfigSummary(saved);
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
let apiKey = saved?.apiKey;
|
|
1338
|
+
if (hasApiUpdate || !hasMutation) {
|
|
1339
|
+
apiKey = await resolveConfigureApiKey(opts);
|
|
1340
|
+
}
|
|
1341
|
+
if (!apiKey) {
|
|
1342
|
+
const envKey = process.env.PUBBLUE_API_KEY?.trim();
|
|
1343
|
+
if (envKey) {
|
|
1344
|
+
apiKey = envKey;
|
|
1345
|
+
} else {
|
|
1346
|
+
throw new Error(
|
|
1347
|
+
"No API key available. Provide --api-key/--api-key-stdin (or run plain `pubblue configure` first)."
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
const nextBridge = { ...saved?.bridge ?? {} };
|
|
1352
|
+
for (const entry of opts.set) {
|
|
1353
|
+
const { key, value } = parseSetInput(entry);
|
|
1354
|
+
applyBridgeSet(nextBridge, key, value);
|
|
1355
|
+
}
|
|
1356
|
+
for (const key of opts.unset) {
|
|
1357
|
+
applyBridgeUnset(nextBridge, key.trim());
|
|
1358
|
+
}
|
|
1359
|
+
const nextConfig = {
|
|
1360
|
+
apiKey,
|
|
1361
|
+
bridge: hasBridgeValues(nextBridge) ? nextBridge : void 0
|
|
1362
|
+
};
|
|
1363
|
+
saveConfig(nextConfig);
|
|
1364
|
+
console.log("Configuration saved.");
|
|
1365
|
+
if (opts.show || hasSet || hasUnset) {
|
|
1366
|
+
printConfigSummary(nextConfig);
|
|
1367
|
+
}
|
|
1368
|
+
} catch (error) {
|
|
1369
|
+
const message = error instanceof Error ? error.message : "Failed to configure CLI.";
|
|
1370
|
+
console.error(message);
|
|
1371
|
+
process.exit(1);
|
|
1372
|
+
}
|
|
840
1373
|
}
|
|
841
|
-
|
|
1374
|
+
);
|
|
842
1375
|
program.command("create").description("Create a new publication").argument("[file]", "Path to the file (reads stdin if omitted)").option("--slug <slug>", "Custom slug for the URL").option("--title <title>", "Title for the publication").option("--public", "Make the publication public").option("--private", "Make the publication private (default)").option("--expires <duration>", "Auto-delete after duration (e.g. 1h, 24h, 7d)").action(
|
|
843
1376
|
async (fileArg, opts) => {
|
|
844
1377
|
const client = createClient();
|