pubblue 0.2.0 → 0.4.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/dist/chunk-56IKFMJ2.js +40 -0
- package/dist/chunk-BV423NLA.js +57 -0
- package/dist/chunk-OLY5PC4A.js +311 -0
- package/dist/index.js +459 -66
- package/dist/tunnel-daemon-CHNV373I.js +7 -0
- package/dist/tunnel-daemon-entry.d.ts +2 -0
- package/dist/tunnel-daemon-entry.js +24 -0
- package/package.json +7 -5
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// src/lib/bridge-protocol.ts
|
|
2
|
+
var CONTROL_CHANNEL = "_control";
|
|
3
|
+
var CHANNELS = {
|
|
4
|
+
CHAT: "chat",
|
|
5
|
+
CANVAS: "canvas",
|
|
6
|
+
AUDIO: "audio",
|
|
7
|
+
MEDIA: "media",
|
|
8
|
+
FILE: "file"
|
|
9
|
+
};
|
|
10
|
+
var idCounter = 0;
|
|
11
|
+
function generateMessageId() {
|
|
12
|
+
const ts = Date.now().toString(36);
|
|
13
|
+
const seq = (idCounter++).toString(36);
|
|
14
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
15
|
+
return `${ts}-${seq}-${rand}`;
|
|
16
|
+
}
|
|
17
|
+
function encodeMessage(msg) {
|
|
18
|
+
return JSON.stringify(msg);
|
|
19
|
+
}
|
|
20
|
+
function decodeMessage(raw) {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(raw);
|
|
23
|
+
if (parsed && typeof parsed.id === "string" && typeof parsed.type === "string") {
|
|
24
|
+
return parsed;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
var MAX_TUNNEL_EXPIRY_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
32
|
+
var DEFAULT_TUNNEL_EXPIRY_MS = 24 * 60 * 60 * 1e3;
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
CONTROL_CHANNEL,
|
|
36
|
+
CHANNELS,
|
|
37
|
+
generateMessageId,
|
|
38
|
+
encodeMessage,
|
|
39
|
+
decodeMessage
|
|
40
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// src/lib/tunnel-api.ts
|
|
2
|
+
var TunnelApiClient = class {
|
|
3
|
+
constructor(baseUrl, apiKey) {
|
|
4
|
+
this.baseUrl = baseUrl;
|
|
5
|
+
this.apiKey = apiKey;
|
|
6
|
+
}
|
|
7
|
+
async request(path, options = {}) {
|
|
8
|
+
const url = new URL(path, this.baseUrl);
|
|
9
|
+
const res = await fetch(url, {
|
|
10
|
+
...options,
|
|
11
|
+
headers: {
|
|
12
|
+
"Content-Type": "application/json",
|
|
13
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
14
|
+
...options.headers
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
let data;
|
|
18
|
+
try {
|
|
19
|
+
data = await res.json();
|
|
20
|
+
} catch {
|
|
21
|
+
data = {};
|
|
22
|
+
}
|
|
23
|
+
if (!res.ok) throw new Error(data.error || `Request failed: ${res.status}`);
|
|
24
|
+
return data;
|
|
25
|
+
}
|
|
26
|
+
async create(opts) {
|
|
27
|
+
return this.request("/api/v1/tunnels", {
|
|
28
|
+
method: "POST",
|
|
29
|
+
body: JSON.stringify(opts)
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async get(tunnelId) {
|
|
33
|
+
const data = await this.request(
|
|
34
|
+
`/api/v1/tunnels/${encodeURIComponent(tunnelId)}`
|
|
35
|
+
);
|
|
36
|
+
return data.tunnel;
|
|
37
|
+
}
|
|
38
|
+
async list() {
|
|
39
|
+
const data = await this.request("/api/v1/tunnels/");
|
|
40
|
+
return data.tunnels;
|
|
41
|
+
}
|
|
42
|
+
async signal(tunnelId, opts) {
|
|
43
|
+
await this.request(`/api/v1/tunnels/${encodeURIComponent(tunnelId)}/signal`, {
|
|
44
|
+
method: "PATCH",
|
|
45
|
+
body: JSON.stringify(opts)
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async close(tunnelId) {
|
|
49
|
+
await this.request(`/api/v1/tunnels/${encodeURIComponent(tunnelId)}`, {
|
|
50
|
+
method: "DELETE"
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export {
|
|
56
|
+
TunnelApiClient
|
|
57
|
+
};
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CHANNELS,
|
|
3
|
+
CONTROL_CHANNEL,
|
|
4
|
+
decodeMessage,
|
|
5
|
+
encodeMessage
|
|
6
|
+
} from "./chunk-56IKFMJ2.js";
|
|
7
|
+
|
|
8
|
+
// src/lib/tunnel-daemon.ts
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
import * as net from "net";
|
|
11
|
+
import * as path from "path";
|
|
12
|
+
async function startDaemon(config) {
|
|
13
|
+
const { tunnelId, apiClient, socketPath, infoPath } = config;
|
|
14
|
+
const ndc = await import("node-datachannel");
|
|
15
|
+
const buffer = { messages: [] };
|
|
16
|
+
const startTime = Date.now();
|
|
17
|
+
let connected = false;
|
|
18
|
+
let pollingInterval = null;
|
|
19
|
+
let lastBrowserCandidateCount = 0;
|
|
20
|
+
let remoteDescriptionApplied = false;
|
|
21
|
+
const pendingRemoteCandidates = [];
|
|
22
|
+
const peer = new ndc.PeerConnection("agent", {
|
|
23
|
+
iceServers: ["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"]
|
|
24
|
+
});
|
|
25
|
+
const channels = /* @__PURE__ */ new Map();
|
|
26
|
+
const pendingInboundBinaryMeta = /* @__PURE__ */ new Map();
|
|
27
|
+
function openDataChannel(name) {
|
|
28
|
+
const existing = channels.get(name);
|
|
29
|
+
if (existing) return existing;
|
|
30
|
+
const dc = peer.createDataChannel(name, { ordered: true });
|
|
31
|
+
setupChannel(name, dc);
|
|
32
|
+
return dc;
|
|
33
|
+
}
|
|
34
|
+
async function waitForChannelOpen(dc, timeoutMs = 5e3) {
|
|
35
|
+
if (dc.isOpen()) return;
|
|
36
|
+
await new Promise((resolve, reject) => {
|
|
37
|
+
let settled = false;
|
|
38
|
+
const timeout = setTimeout(() => {
|
|
39
|
+
if (settled) return;
|
|
40
|
+
settled = true;
|
|
41
|
+
reject(new Error("DataChannel open timed out"));
|
|
42
|
+
}, timeoutMs);
|
|
43
|
+
dc.onOpen(() => {
|
|
44
|
+
if (settled) return;
|
|
45
|
+
settled = true;
|
|
46
|
+
clearTimeout(timeout);
|
|
47
|
+
resolve();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function setupChannel(name, dc) {
|
|
52
|
+
channels.set(name, dc);
|
|
53
|
+
dc.onMessage((data) => {
|
|
54
|
+
if (typeof data === "string") {
|
|
55
|
+
const msg = decodeMessage(data);
|
|
56
|
+
if (msg) {
|
|
57
|
+
if (msg.type === "binary" && !msg.data) {
|
|
58
|
+
pendingInboundBinaryMeta.set(name, msg);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
buffer.messages.push({ channel: name, msg, timestamp: Date.now() });
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
const pendingMeta = pendingInboundBinaryMeta.get(name);
|
|
65
|
+
if (pendingMeta) pendingInboundBinaryMeta.delete(name);
|
|
66
|
+
const binMsg = pendingMeta ? {
|
|
67
|
+
id: pendingMeta.id,
|
|
68
|
+
type: "binary",
|
|
69
|
+
data: data.toString("base64"),
|
|
70
|
+
meta: { ...pendingMeta.meta, size: data.length }
|
|
71
|
+
} : {
|
|
72
|
+
id: `bin-${Date.now()}`,
|
|
73
|
+
type: "binary",
|
|
74
|
+
data: data.toString("base64"),
|
|
75
|
+
meta: { size: data.length }
|
|
76
|
+
};
|
|
77
|
+
buffer.messages.push({ channel: name, msg: binMsg, timestamp: Date.now() });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
openDataChannel(CONTROL_CHANNEL);
|
|
82
|
+
openDataChannel(CHANNELS.CHAT);
|
|
83
|
+
openDataChannel(CHANNELS.CANVAS);
|
|
84
|
+
const localCandidates = [];
|
|
85
|
+
peer.onLocalCandidate((candidate, mid) => {
|
|
86
|
+
localCandidates.push(JSON.stringify({ candidate, sdpMid: mid }));
|
|
87
|
+
});
|
|
88
|
+
peer.onStateChange((state) => {
|
|
89
|
+
if (state === "connected") {
|
|
90
|
+
connected = true;
|
|
91
|
+
if (pollingInterval) {
|
|
92
|
+
clearInterval(pollingInterval);
|
|
93
|
+
pollingInterval = null;
|
|
94
|
+
}
|
|
95
|
+
} else if (state === "disconnected" || state === "failed") {
|
|
96
|
+
connected = false;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
peer.onDataChannel((dc) => {
|
|
100
|
+
setupChannel(dc.getLabel(), dc);
|
|
101
|
+
});
|
|
102
|
+
const offer = await new Promise((resolve) => {
|
|
103
|
+
peer.onLocalDescription((sdp, type) => {
|
|
104
|
+
resolve(JSON.stringify({ sdp, type }));
|
|
105
|
+
});
|
|
106
|
+
peer.setLocalDescription();
|
|
107
|
+
});
|
|
108
|
+
await apiClient.signal(tunnelId, { offer });
|
|
109
|
+
setTimeout(async () => {
|
|
110
|
+
if (localCandidates.length > 0) {
|
|
111
|
+
await apiClient.signal(tunnelId, { candidates: localCandidates }).catch(() => {
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}, 1e3);
|
|
115
|
+
let lastSentCandidateCount = 0;
|
|
116
|
+
const candidateInterval = setInterval(async () => {
|
|
117
|
+
if (localCandidates.length > lastSentCandidateCount) {
|
|
118
|
+
const newOnes = localCandidates.slice(lastSentCandidateCount);
|
|
119
|
+
lastSentCandidateCount = localCandidates.length;
|
|
120
|
+
await apiClient.signal(tunnelId, { candidates: newOnes }).catch(() => {
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}, 500);
|
|
124
|
+
setTimeout(() => clearInterval(candidateInterval), 3e4);
|
|
125
|
+
pollingInterval = setInterval(async () => {
|
|
126
|
+
try {
|
|
127
|
+
const tunnel = await apiClient.get(tunnelId);
|
|
128
|
+
if (tunnel.browserAnswer && !remoteDescriptionApplied) {
|
|
129
|
+
try {
|
|
130
|
+
const answer = JSON.parse(tunnel.browserAnswer);
|
|
131
|
+
peer.setRemoteDescription(answer.sdp, answer.type);
|
|
132
|
+
remoteDescriptionApplied = true;
|
|
133
|
+
while (pendingRemoteCandidates.length > 0) {
|
|
134
|
+
const next = pendingRemoteCandidates.shift();
|
|
135
|
+
if (!next) break;
|
|
136
|
+
try {
|
|
137
|
+
peer.addRemoteCandidate(next.candidate, next.sdpMid);
|
|
138
|
+
} catch {
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (tunnel.browserCandidates.length > lastBrowserCandidateCount) {
|
|
145
|
+
const newCandidates = tunnel.browserCandidates.slice(lastBrowserCandidateCount);
|
|
146
|
+
lastBrowserCandidateCount = tunnel.browserCandidates.length;
|
|
147
|
+
for (const c of newCandidates) {
|
|
148
|
+
try {
|
|
149
|
+
const parsed = JSON.parse(c);
|
|
150
|
+
if (typeof parsed.candidate !== "string") continue;
|
|
151
|
+
const sdpMid = typeof parsed.sdpMid === "string" ? parsed.sdpMid : "0";
|
|
152
|
+
if (!remoteDescriptionApplied) {
|
|
153
|
+
pendingRemoteCandidates.push({ candidate: parsed.candidate, sdpMid });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
peer.addRemoteCandidate(parsed.candidate, sdpMid);
|
|
157
|
+
} catch {
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
}
|
|
163
|
+
}, 500);
|
|
164
|
+
if (fs.existsSync(socketPath)) {
|
|
165
|
+
const infoFile = infoPath;
|
|
166
|
+
let stale = true;
|
|
167
|
+
try {
|
|
168
|
+
const raw = fs.readFileSync(infoFile, "utf-8");
|
|
169
|
+
const info = JSON.parse(raw);
|
|
170
|
+
process.kill(info.pid, 0);
|
|
171
|
+
stale = false;
|
|
172
|
+
} catch {
|
|
173
|
+
stale = true;
|
|
174
|
+
}
|
|
175
|
+
if (stale) {
|
|
176
|
+
try {
|
|
177
|
+
fs.unlinkSync(socketPath);
|
|
178
|
+
} catch {
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
throw new Error(`Daemon already running (socket: ${socketPath})`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const ipcServer = net.createServer((conn) => {
|
|
185
|
+
let data = "";
|
|
186
|
+
conn.on("data", (chunk) => {
|
|
187
|
+
data += chunk.toString();
|
|
188
|
+
const newlineIdx = data.indexOf("\n");
|
|
189
|
+
if (newlineIdx === -1) return;
|
|
190
|
+
const line = data.slice(0, newlineIdx);
|
|
191
|
+
data = data.slice(newlineIdx + 1);
|
|
192
|
+
let request;
|
|
193
|
+
try {
|
|
194
|
+
request = JSON.parse(line);
|
|
195
|
+
} catch {
|
|
196
|
+
conn.write(`${JSON.stringify({ ok: false, error: "Invalid JSON" })}
|
|
197
|
+
`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
handleIpcRequest(request).then((response) => conn.write(`${JSON.stringify(response)}
|
|
201
|
+
`)).catch((err) => conn.write(`${JSON.stringify({ ok: false, error: String(err) })}
|
|
202
|
+
`));
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
ipcServer.listen(socketPath);
|
|
206
|
+
async function handleIpcRequest(req) {
|
|
207
|
+
switch (req.method) {
|
|
208
|
+
case "write": {
|
|
209
|
+
const channel = req.params.channel || CHANNELS.CHAT;
|
|
210
|
+
const msg = req.params.msg;
|
|
211
|
+
const binaryBase64 = typeof req.params.binaryBase64 === "string" ? req.params.binaryBase64 : void 0;
|
|
212
|
+
const dc = channels.get(channel);
|
|
213
|
+
let targetDc = dc;
|
|
214
|
+
if (!targetDc) {
|
|
215
|
+
const newDc = openDataChannel(channel);
|
|
216
|
+
targetDc = newDc;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
await waitForChannelOpen(targetDc);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
222
|
+
return { ok: false, error: `Channel "${channel}" not open: ${message}` };
|
|
223
|
+
}
|
|
224
|
+
if (msg.type === "binary" && binaryBase64) {
|
|
225
|
+
const payload = Buffer.from(binaryBase64, "base64");
|
|
226
|
+
targetDc.sendMessage(
|
|
227
|
+
encodeMessage({
|
|
228
|
+
...msg,
|
|
229
|
+
meta: {
|
|
230
|
+
...msg.meta || {},
|
|
231
|
+
size: payload.length
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
);
|
|
235
|
+
targetDc.sendMessageBinary(payload);
|
|
236
|
+
} else {
|
|
237
|
+
targetDc.sendMessage(encodeMessage(msg));
|
|
238
|
+
}
|
|
239
|
+
return { ok: true };
|
|
240
|
+
}
|
|
241
|
+
case "read": {
|
|
242
|
+
const channel = req.params.channel;
|
|
243
|
+
let msgs;
|
|
244
|
+
if (channel) {
|
|
245
|
+
msgs = buffer.messages.filter((m) => m.channel === channel);
|
|
246
|
+
buffer.messages = buffer.messages.filter((m) => m.channel !== channel);
|
|
247
|
+
} else {
|
|
248
|
+
msgs = [...buffer.messages];
|
|
249
|
+
buffer.messages = [];
|
|
250
|
+
}
|
|
251
|
+
return { ok: true, messages: msgs };
|
|
252
|
+
}
|
|
253
|
+
case "channels": {
|
|
254
|
+
const chList = [...channels.keys()].map((name) => ({
|
|
255
|
+
name,
|
|
256
|
+
direction: "bidi"
|
|
257
|
+
}));
|
|
258
|
+
return { ok: true, channels: chList };
|
|
259
|
+
}
|
|
260
|
+
case "status": {
|
|
261
|
+
return {
|
|
262
|
+
ok: true,
|
|
263
|
+
connected,
|
|
264
|
+
uptime: Math.floor((Date.now() - startTime) / 1e3),
|
|
265
|
+
channels: [...channels.keys()],
|
|
266
|
+
bufferedMessages: buffer.messages.length
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
case "close": {
|
|
270
|
+
void shutdown();
|
|
271
|
+
return { ok: true };
|
|
272
|
+
}
|
|
273
|
+
default:
|
|
274
|
+
return { ok: false, error: `Unknown method: ${req.method}` };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async function shutdown() {
|
|
278
|
+
if (pollingInterval) clearInterval(pollingInterval);
|
|
279
|
+
for (const dc of channels.values()) dc.close();
|
|
280
|
+
peer.close();
|
|
281
|
+
ipcServer.close();
|
|
282
|
+
try {
|
|
283
|
+
fs.unlinkSync(socketPath);
|
|
284
|
+
} catch {
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
fs.unlinkSync(infoPath);
|
|
288
|
+
} catch {
|
|
289
|
+
}
|
|
290
|
+
await apiClient.close(tunnelId).catch(() => {
|
|
291
|
+
});
|
|
292
|
+
process.exit(0);
|
|
293
|
+
}
|
|
294
|
+
process.on("SIGTERM", () => void shutdown());
|
|
295
|
+
process.on("SIGINT", () => void shutdown());
|
|
296
|
+
const infoDir = path.dirname(infoPath);
|
|
297
|
+
if (!fs.existsSync(infoDir)) fs.mkdirSync(infoDir, { recursive: true });
|
|
298
|
+
fs.writeFileSync(
|
|
299
|
+
infoPath,
|
|
300
|
+
JSON.stringify({
|
|
301
|
+
pid: process.pid,
|
|
302
|
+
tunnelId,
|
|
303
|
+
socketPath,
|
|
304
|
+
startedAt: startTime
|
|
305
|
+
})
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export {
|
|
310
|
+
startDaemon
|
|
311
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -1,60 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
TunnelApiClient
|
|
4
|
+
} from "./chunk-BV423NLA.js";
|
|
5
|
+
import {
|
|
6
|
+
generateMessageId
|
|
7
|
+
} from "./chunk-56IKFMJ2.js";
|
|
2
8
|
|
|
3
9
|
// src/index.ts
|
|
4
|
-
import * as
|
|
5
|
-
import * as
|
|
10
|
+
import * as fs3 from "fs";
|
|
11
|
+
import * as path3 from "path";
|
|
6
12
|
import { createInterface } from "readline/promises";
|
|
7
13
|
import { Command } from "commander";
|
|
8
14
|
|
|
9
|
-
// src/
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
this.apiKey = apiKey;
|
|
14
|
-
}
|
|
15
|
-
async request(path3, options = {}) {
|
|
16
|
-
const url = new URL(path3, this.baseUrl);
|
|
17
|
-
const res = await fetch(url, {
|
|
18
|
-
...options,
|
|
19
|
-
headers: {
|
|
20
|
-
"Content-Type": "application/json",
|
|
21
|
-
Authorization: `Bearer ${this.apiKey}`,
|
|
22
|
-
...options.headers
|
|
23
|
-
}
|
|
24
|
-
});
|
|
25
|
-
const data = await res.json();
|
|
26
|
-
if (!res.ok) {
|
|
27
|
-
throw new Error(data.error || `Request failed with status ${res.status}`);
|
|
28
|
-
}
|
|
29
|
-
return data;
|
|
30
|
-
}
|
|
31
|
-
async create(opts) {
|
|
32
|
-
return this.request("/api/v1/publications", {
|
|
33
|
-
method: "POST",
|
|
34
|
-
body: JSON.stringify(opts)
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
async get(slug) {
|
|
38
|
-
const data = await this.request(`/api/v1/publications/${encodeURIComponent(slug)}`);
|
|
39
|
-
return data.publication;
|
|
40
|
-
}
|
|
41
|
-
async list() {
|
|
42
|
-
const data = await this.request("/api/v1/publications");
|
|
43
|
-
return data.publications;
|
|
44
|
-
}
|
|
45
|
-
async update(opts) {
|
|
46
|
-
const { slug, ...body } = opts;
|
|
47
|
-
return this.request(`/api/v1/publications/${encodeURIComponent(slug)}`, {
|
|
48
|
-
method: "PATCH",
|
|
49
|
-
body: JSON.stringify(body)
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
async remove(slug) {
|
|
53
|
-
await this.request(`/api/v1/publications/${encodeURIComponent(slug)}`, {
|
|
54
|
-
method: "DELETE"
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
};
|
|
15
|
+
// src/commands/tunnel.ts
|
|
16
|
+
import { fork } from "child_process";
|
|
17
|
+
import * as fs2 from "fs";
|
|
18
|
+
import * as path2 from "path";
|
|
58
19
|
|
|
59
20
|
// src/lib/config.ts
|
|
60
21
|
import * as fs from "fs";
|
|
@@ -110,6 +71,426 @@ function getConfig(homeDir) {
|
|
|
110
71
|
};
|
|
111
72
|
}
|
|
112
73
|
|
|
74
|
+
// src/lib/tunnel-ipc.ts
|
|
75
|
+
import * as net from "net";
|
|
76
|
+
function getSocketPath(tunnelId) {
|
|
77
|
+
return `/tmp/pubblue-${tunnelId}.sock`;
|
|
78
|
+
}
|
|
79
|
+
async function ipcCall(socketPath, request) {
|
|
80
|
+
return new Promise((resolve3, reject) => {
|
|
81
|
+
let settled = false;
|
|
82
|
+
let timeoutId = null;
|
|
83
|
+
const finish = (fn) => {
|
|
84
|
+
if (settled) return;
|
|
85
|
+
settled = true;
|
|
86
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
87
|
+
fn();
|
|
88
|
+
};
|
|
89
|
+
const client = net.createConnection(socketPath, () => {
|
|
90
|
+
client.write(`${JSON.stringify(request)}
|
|
91
|
+
`);
|
|
92
|
+
});
|
|
93
|
+
let data = "";
|
|
94
|
+
client.on("data", (chunk) => {
|
|
95
|
+
data += chunk.toString();
|
|
96
|
+
const newlineIdx = data.indexOf("\n");
|
|
97
|
+
if (newlineIdx !== -1) {
|
|
98
|
+
const line = data.slice(0, newlineIdx);
|
|
99
|
+
client.end();
|
|
100
|
+
try {
|
|
101
|
+
finish(() => resolve3(JSON.parse(line)));
|
|
102
|
+
} catch {
|
|
103
|
+
finish(() => reject(new Error("Invalid response from daemon")));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
client.on("error", (err) => {
|
|
108
|
+
if (err.code === "ECONNREFUSED" || err.code === "ENOENT") {
|
|
109
|
+
finish(() => reject(new Error("Daemon not running. Is the tunnel still active?")));
|
|
110
|
+
} else {
|
|
111
|
+
finish(() => reject(err));
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
client.on("end", () => {
|
|
115
|
+
if (!data.includes("\n")) {
|
|
116
|
+
finish(() => reject(new Error("Daemon closed connection unexpectedly")));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
timeoutId = setTimeout(() => {
|
|
120
|
+
client.destroy();
|
|
121
|
+
finish(() => reject(new Error("Daemon request timed out")));
|
|
122
|
+
}, 1e4);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/commands/tunnel.ts
|
|
127
|
+
var TEXT_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
128
|
+
".txt",
|
|
129
|
+
".md",
|
|
130
|
+
".markdown",
|
|
131
|
+
".json",
|
|
132
|
+
".csv",
|
|
133
|
+
".xml",
|
|
134
|
+
".yaml",
|
|
135
|
+
".yml",
|
|
136
|
+
".js",
|
|
137
|
+
".mjs",
|
|
138
|
+
".cjs",
|
|
139
|
+
".ts",
|
|
140
|
+
".tsx",
|
|
141
|
+
".jsx",
|
|
142
|
+
".css",
|
|
143
|
+
".scss",
|
|
144
|
+
".sass",
|
|
145
|
+
".less",
|
|
146
|
+
".log"
|
|
147
|
+
]);
|
|
148
|
+
function getMimeType(filePath) {
|
|
149
|
+
const ext = path2.extname(filePath).toLowerCase();
|
|
150
|
+
const mimeByExt = {
|
|
151
|
+
".html": "text/html; charset=utf-8",
|
|
152
|
+
".htm": "text/html; charset=utf-8",
|
|
153
|
+
".txt": "text/plain; charset=utf-8",
|
|
154
|
+
".md": "text/markdown; charset=utf-8",
|
|
155
|
+
".markdown": "text/markdown; charset=utf-8",
|
|
156
|
+
".json": "application/json",
|
|
157
|
+
".csv": "text/csv; charset=utf-8",
|
|
158
|
+
".xml": "application/xml",
|
|
159
|
+
".yaml": "application/x-yaml",
|
|
160
|
+
".yml": "application/x-yaml",
|
|
161
|
+
".png": "image/png",
|
|
162
|
+
".jpg": "image/jpeg",
|
|
163
|
+
".jpeg": "image/jpeg",
|
|
164
|
+
".gif": "image/gif",
|
|
165
|
+
".webp": "image/webp",
|
|
166
|
+
".svg": "image/svg+xml",
|
|
167
|
+
".pdf": "application/pdf",
|
|
168
|
+
".zip": "application/zip",
|
|
169
|
+
".mp3": "audio/mpeg",
|
|
170
|
+
".wav": "audio/wav",
|
|
171
|
+
".mp4": "video/mp4"
|
|
172
|
+
};
|
|
173
|
+
return mimeByExt[ext] || "application/octet-stream";
|
|
174
|
+
}
|
|
175
|
+
function tunnelInfoDir() {
|
|
176
|
+
const dir = path2.join(
|
|
177
|
+
process.env.HOME || process.env.USERPROFILE || "/tmp",
|
|
178
|
+
".config",
|
|
179
|
+
"pubblue",
|
|
180
|
+
"tunnels"
|
|
181
|
+
);
|
|
182
|
+
if (!fs2.existsSync(dir)) fs2.mkdirSync(dir, { recursive: true });
|
|
183
|
+
return dir;
|
|
184
|
+
}
|
|
185
|
+
function tunnelInfoPath(tunnelId) {
|
|
186
|
+
return path2.join(tunnelInfoDir(), `${tunnelId}.json`);
|
|
187
|
+
}
|
|
188
|
+
function createApiClient() {
|
|
189
|
+
const config = getConfig();
|
|
190
|
+
return new TunnelApiClient(config.baseUrl, config.apiKey);
|
|
191
|
+
}
|
|
192
|
+
async function ensureNodeDatachannelAvailable() {
|
|
193
|
+
try {
|
|
194
|
+
await import("node-datachannel");
|
|
195
|
+
} catch (error) {
|
|
196
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
197
|
+
console.error("node-datachannel native module is not available.");
|
|
198
|
+
console.error("Run `pnpm rebuild node-datachannel` in the cli package and retry.");
|
|
199
|
+
console.error(`Details: ${message}`);
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function isDaemonRunning(tunnelId) {
|
|
204
|
+
const infoPath = tunnelInfoPath(tunnelId);
|
|
205
|
+
if (!fs2.existsSync(infoPath)) return false;
|
|
206
|
+
try {
|
|
207
|
+
const info = JSON.parse(fs2.readFileSync(infoPath, "utf-8"));
|
|
208
|
+
process.kill(info.pid, 0);
|
|
209
|
+
return true;
|
|
210
|
+
} catch {
|
|
211
|
+
try {
|
|
212
|
+
fs2.unlinkSync(infoPath);
|
|
213
|
+
} catch {
|
|
214
|
+
}
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function registerTunnelCommands(program2) {
|
|
219
|
+
const tunnel = program2.command("tunnel").description("P2P encrypted tunnel to browser");
|
|
220
|
+
tunnel.command("start").description("Start a new tunnel (spawns background daemon)").option("--title <title>", "Tunnel title").option("--expires <duration>", "Auto-close after duration (e.g. 4h, 1d)", "24h").option("--foreground", "Run in foreground (don't fork)").action(async (opts) => {
|
|
221
|
+
await ensureNodeDatachannelAvailable();
|
|
222
|
+
const apiClient = createApiClient();
|
|
223
|
+
const result = await apiClient.create({
|
|
224
|
+
title: opts.title,
|
|
225
|
+
expiresIn: opts.expires
|
|
226
|
+
});
|
|
227
|
+
const socketPath = getSocketPath(result.tunnelId);
|
|
228
|
+
const infoPath = tunnelInfoPath(result.tunnelId);
|
|
229
|
+
if (opts.foreground) {
|
|
230
|
+
const { startDaemon } = await import("./tunnel-daemon-CHNV373I.js");
|
|
231
|
+
console.log(`Tunnel started: ${result.url}`);
|
|
232
|
+
console.log(`Tunnel ID: ${result.tunnelId}`);
|
|
233
|
+
console.log(`Expires: ${new Date(result.expiresAt).toISOString()}`);
|
|
234
|
+
console.log("Running in foreground. Press Ctrl+C to stop.");
|
|
235
|
+
await startDaemon({
|
|
236
|
+
tunnelId: result.tunnelId,
|
|
237
|
+
apiClient,
|
|
238
|
+
socketPath,
|
|
239
|
+
infoPath
|
|
240
|
+
});
|
|
241
|
+
} else {
|
|
242
|
+
const daemonScript = path2.join(import.meta.dirname, "..", "tunnel-daemon-entry.js");
|
|
243
|
+
const config = getConfig();
|
|
244
|
+
const child = fork(daemonScript, [], {
|
|
245
|
+
detached: true,
|
|
246
|
+
stdio: "ignore",
|
|
247
|
+
env: {
|
|
248
|
+
...process.env,
|
|
249
|
+
PUBBLUE_DAEMON_TUNNEL_ID: result.tunnelId,
|
|
250
|
+
PUBBLUE_DAEMON_BASE_URL: config.baseUrl,
|
|
251
|
+
PUBBLUE_DAEMON_API_KEY: config.apiKey,
|
|
252
|
+
PUBBLUE_DAEMON_SOCKET: socketPath,
|
|
253
|
+
PUBBLUE_DAEMON_INFO: infoPath
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
child.unref();
|
|
257
|
+
console.log(`Tunnel started: ${result.url}`);
|
|
258
|
+
console.log(`Tunnel ID: ${result.tunnelId}`);
|
|
259
|
+
console.log(`Expires: ${new Date(result.expiresAt).toISOString()}`);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
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(
|
|
263
|
+
async (messageArg, opts) => {
|
|
264
|
+
let msg;
|
|
265
|
+
let binaryBase64;
|
|
266
|
+
if (opts.file) {
|
|
267
|
+
const filePath = path2.resolve(opts.file);
|
|
268
|
+
const ext = path2.extname(filePath).toLowerCase();
|
|
269
|
+
const bytes = fs2.readFileSync(filePath);
|
|
270
|
+
const filename = path2.basename(filePath);
|
|
271
|
+
if (ext === ".html" || ext === ".htm") {
|
|
272
|
+
msg = {
|
|
273
|
+
id: generateMessageId(),
|
|
274
|
+
type: "html",
|
|
275
|
+
data: bytes.toString("utf-8"),
|
|
276
|
+
meta: { title: filename, filename, mime: getMimeType(filePath), size: bytes.length }
|
|
277
|
+
};
|
|
278
|
+
} else if (TEXT_FILE_EXTENSIONS.has(ext)) {
|
|
279
|
+
msg = {
|
|
280
|
+
id: generateMessageId(),
|
|
281
|
+
type: "text",
|
|
282
|
+
data: bytes.toString("utf-8"),
|
|
283
|
+
meta: { filename, mime: getMimeType(filePath), size: bytes.length }
|
|
284
|
+
};
|
|
285
|
+
} else {
|
|
286
|
+
msg = {
|
|
287
|
+
id: generateMessageId(),
|
|
288
|
+
type: "binary",
|
|
289
|
+
meta: { filename, mime: getMimeType(filePath), size: bytes.length }
|
|
290
|
+
};
|
|
291
|
+
binaryBase64 = bytes.toString("base64");
|
|
292
|
+
}
|
|
293
|
+
} else if (messageArg) {
|
|
294
|
+
msg = {
|
|
295
|
+
id: generateMessageId(),
|
|
296
|
+
type: "text",
|
|
297
|
+
data: messageArg
|
|
298
|
+
};
|
|
299
|
+
} else {
|
|
300
|
+
const chunks = [];
|
|
301
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
302
|
+
msg = {
|
|
303
|
+
id: generateMessageId(),
|
|
304
|
+
type: "text",
|
|
305
|
+
data: Buffer.concat(chunks).toString("utf-8").trim()
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
const tunnelId = opts.tunnel || await resolveActiveTunnel();
|
|
309
|
+
const socketPath = getSocketPath(tunnelId);
|
|
310
|
+
const response = await ipcCall(socketPath, {
|
|
311
|
+
method: "write",
|
|
312
|
+
params: { channel: opts.channel, msg, binaryBase64 }
|
|
313
|
+
});
|
|
314
|
+
if (!response.ok) {
|
|
315
|
+
console.error(`Failed: ${response.error}`);
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
);
|
|
320
|
+
tunnel.command("read").description("Read buffered messages from channels").argument("[tunnelId]", "Tunnel ID (auto-detected if one active)").option("-c, --channel <channel>", "Filter by channel").option("--follow", "Stream messages continuously").action(
|
|
321
|
+
async (tunnelIdArg, opts) => {
|
|
322
|
+
const tunnelId = tunnelIdArg || await resolveActiveTunnel();
|
|
323
|
+
const socketPath = getSocketPath(tunnelId);
|
|
324
|
+
if (opts.follow) {
|
|
325
|
+
while (true) {
|
|
326
|
+
const response = await ipcCall(socketPath, {
|
|
327
|
+
method: "read",
|
|
328
|
+
params: { channel: opts.channel }
|
|
329
|
+
}).catch(() => null);
|
|
330
|
+
if (!response) {
|
|
331
|
+
console.error("Daemon disconnected.");
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
if (response.messages && response.messages.length > 0) {
|
|
335
|
+
for (const m of response.messages) {
|
|
336
|
+
console.log(JSON.stringify(m));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
const response = await ipcCall(socketPath, {
|
|
343
|
+
method: "read",
|
|
344
|
+
params: { channel: opts.channel }
|
|
345
|
+
});
|
|
346
|
+
if (!response.ok) {
|
|
347
|
+
console.error(`Failed: ${response.error}`);
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
console.log(JSON.stringify(response.messages || [], null, 2));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
);
|
|
354
|
+
tunnel.command("channels").description("List active channels").argument("[tunnelId]", "Tunnel ID").action(async (tunnelIdArg) => {
|
|
355
|
+
const tunnelId = tunnelIdArg || await resolveActiveTunnel();
|
|
356
|
+
const socketPath = getSocketPath(tunnelId);
|
|
357
|
+
const response = await ipcCall(socketPath, { method: "channels", params: {} });
|
|
358
|
+
if (response.channels) {
|
|
359
|
+
for (const ch of response.channels) {
|
|
360
|
+
console.log(` ${ch.name} [${ch.direction}]`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
tunnel.command("status").description("Check tunnel connection status").argument("[tunnelId]", "Tunnel ID").action(async (tunnelIdArg) => {
|
|
365
|
+
const tunnelId = tunnelIdArg || await resolveActiveTunnel();
|
|
366
|
+
const socketPath = getSocketPath(tunnelId);
|
|
367
|
+
const response = await ipcCall(socketPath, { method: "status", params: {} });
|
|
368
|
+
console.log(` Status: ${response.connected ? "connected" : "waiting"}`);
|
|
369
|
+
console.log(` Uptime: ${response.uptime}s`);
|
|
370
|
+
const chNames = Array.isArray(response.channels) ? response.channels.map((c) => typeof c === "string" ? c : String(c)) : [];
|
|
371
|
+
console.log(` Channels: ${chNames.join(", ")}`);
|
|
372
|
+
console.log(` Buffered: ${response.bufferedMessages ?? 0} messages`);
|
|
373
|
+
});
|
|
374
|
+
tunnel.command("list").description("List active tunnels").action(async () => {
|
|
375
|
+
const apiClient = createApiClient();
|
|
376
|
+
const tunnels = await apiClient.list();
|
|
377
|
+
if (tunnels.length === 0) {
|
|
378
|
+
console.log("No active tunnels.");
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
for (const t of tunnels) {
|
|
382
|
+
const age = Math.floor((Date.now() - t.createdAt) / 6e4);
|
|
383
|
+
const running = isDaemonRunning(t.tunnelId) ? "running" : "no daemon";
|
|
384
|
+
const conn = t.hasConnection ? "connected" : "waiting";
|
|
385
|
+
console.log(
|
|
386
|
+
` ${t.tunnelId} ${t.title || "(untitled)"} ${conn} ${running} ${age}m ago`
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
tunnel.command("close").description("Close a tunnel and stop its daemon").argument("<tunnelId>", "Tunnel ID").action(async (tunnelId) => {
|
|
391
|
+
const socketPath = getSocketPath(tunnelId);
|
|
392
|
+
let closedByDaemon = false;
|
|
393
|
+
try {
|
|
394
|
+
const daemonResult = await ipcCall(socketPath, { method: "close", params: {} });
|
|
395
|
+
closedByDaemon = daemonResult.ok;
|
|
396
|
+
} catch {
|
|
397
|
+
closedByDaemon = false;
|
|
398
|
+
}
|
|
399
|
+
if (!closedByDaemon) {
|
|
400
|
+
const apiClient = createApiClient();
|
|
401
|
+
try {
|
|
402
|
+
await apiClient.close(tunnelId);
|
|
403
|
+
} catch (error) {
|
|
404
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
405
|
+
console.error(`Failed to close tunnel ${tunnelId}: ${message}`);
|
|
406
|
+
process.exit(1);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
console.log(`Closed: ${tunnelId}`);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
async function resolveActiveTunnel() {
|
|
413
|
+
const dir = tunnelInfoDir();
|
|
414
|
+
const files = fs2.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
415
|
+
const active = [];
|
|
416
|
+
for (const f of files) {
|
|
417
|
+
const tunnelId = f.replace(".json", "");
|
|
418
|
+
if (isDaemonRunning(tunnelId)) active.push(tunnelId);
|
|
419
|
+
}
|
|
420
|
+
if (active.length === 0) {
|
|
421
|
+
console.error("No active tunnels. Run `pubblue tunnel start` first.");
|
|
422
|
+
process.exit(1);
|
|
423
|
+
}
|
|
424
|
+
if (active.length === 1) return active[0];
|
|
425
|
+
console.error(`Multiple active tunnels: ${active.join(", ")}. Specify one.`);
|
|
426
|
+
process.exit(1);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// src/lib/api.ts
|
|
430
|
+
var PubApiClient = class {
|
|
431
|
+
constructor(baseUrl, apiKey) {
|
|
432
|
+
this.baseUrl = baseUrl;
|
|
433
|
+
this.apiKey = apiKey;
|
|
434
|
+
}
|
|
435
|
+
async request(path4, options = {}) {
|
|
436
|
+
const url = new URL(path4, this.baseUrl);
|
|
437
|
+
const res = await fetch(url, {
|
|
438
|
+
...options,
|
|
439
|
+
headers: {
|
|
440
|
+
"Content-Type": "application/json",
|
|
441
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
442
|
+
...options.headers
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
const data = await res.json();
|
|
446
|
+
if (!res.ok) {
|
|
447
|
+
throw new Error(data.error || `Request failed with status ${res.status}`);
|
|
448
|
+
}
|
|
449
|
+
return data;
|
|
450
|
+
}
|
|
451
|
+
async create(opts) {
|
|
452
|
+
return this.request("/api/v1/publications", {
|
|
453
|
+
method: "POST",
|
|
454
|
+
body: JSON.stringify(opts)
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
async get(slug) {
|
|
458
|
+
const data = await this.request(`/api/v1/publications/${encodeURIComponent(slug)}`);
|
|
459
|
+
return data.publication;
|
|
460
|
+
}
|
|
461
|
+
async listPage(cursor, limit) {
|
|
462
|
+
const params = new URLSearchParams();
|
|
463
|
+
if (cursor) params.set("cursor", cursor);
|
|
464
|
+
if (limit) params.set("limit", String(limit));
|
|
465
|
+
const qs = params.toString();
|
|
466
|
+
return this.request(`/api/v1/publications${qs ? `?${qs}` : ""}`);
|
|
467
|
+
}
|
|
468
|
+
async list() {
|
|
469
|
+
const all = [];
|
|
470
|
+
let cursor;
|
|
471
|
+
do {
|
|
472
|
+
const result = await this.listPage(cursor, 100);
|
|
473
|
+
all.push(...result.publications);
|
|
474
|
+
cursor = result.hasMore ? result.cursor : void 0;
|
|
475
|
+
} while (cursor);
|
|
476
|
+
return all;
|
|
477
|
+
}
|
|
478
|
+
async update(opts) {
|
|
479
|
+
const { slug, newSlug, ...rest } = opts;
|
|
480
|
+
const body = { ...rest };
|
|
481
|
+
if (newSlug) body.slug = newSlug;
|
|
482
|
+
return this.request(`/api/v1/publications/${encodeURIComponent(slug)}`, {
|
|
483
|
+
method: "PATCH",
|
|
484
|
+
body: JSON.stringify(body)
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
async remove(slug) {
|
|
488
|
+
await this.request(`/api/v1/publications/${encodeURIComponent(slug)}`, {
|
|
489
|
+
method: "DELETE"
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
|
|
113
494
|
// src/index.ts
|
|
114
495
|
var program = new Command();
|
|
115
496
|
function createClient() {
|
|
@@ -158,17 +539,17 @@ async function resolveConfigureApiKey(opts) {
|
|
|
158
539
|
return readApiKeyFromPrompt();
|
|
159
540
|
}
|
|
160
541
|
function readFile(filePath) {
|
|
161
|
-
const resolved =
|
|
162
|
-
if (!
|
|
542
|
+
const resolved = path3.resolve(filePath);
|
|
543
|
+
if (!fs3.existsSync(resolved)) {
|
|
163
544
|
console.error(`File not found: ${resolved}`);
|
|
164
545
|
process.exit(1);
|
|
165
546
|
}
|
|
166
547
|
return {
|
|
167
|
-
content:
|
|
168
|
-
basename:
|
|
548
|
+
content: fs3.readFileSync(resolved, "utf-8"),
|
|
549
|
+
basename: path3.basename(resolved)
|
|
169
550
|
};
|
|
170
551
|
}
|
|
171
|
-
program.name("pubblue").description("Publish static content and get shareable URLs").version("0.
|
|
552
|
+
program.name("pubblue").description("Publish static content and get shareable URLs").version("0.4.0");
|
|
172
553
|
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").action(async (opts) => {
|
|
173
554
|
try {
|
|
174
555
|
const apiKey = await resolveConfigureApiKey(opts);
|
|
@@ -180,14 +561,13 @@ program.command("configure").description("Configure the CLI with your API key").
|
|
|
180
561
|
process.exit(1);
|
|
181
562
|
}
|
|
182
563
|
});
|
|
183
|
-
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("--
|
|
564
|
+
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("--private", "Make the publication private (default)").option("--expires <duration>", "Auto-delete after duration (e.g. 1h, 24h, 7d)").action(
|
|
184
565
|
async (fileArg, opts) => {
|
|
185
566
|
const client = createClient();
|
|
186
|
-
const filePath = fileArg;
|
|
187
567
|
let content;
|
|
188
568
|
let filename;
|
|
189
|
-
if (
|
|
190
|
-
const file = readFile(
|
|
569
|
+
if (fileArg) {
|
|
570
|
+
const file = readFile(fileArg);
|
|
191
571
|
content = file.content;
|
|
192
572
|
filename = file.basename;
|
|
193
573
|
} else {
|
|
@@ -198,9 +578,13 @@ program.command("create").description("Create a new publication").argument("[fil
|
|
|
198
578
|
filename,
|
|
199
579
|
title: opts.title,
|
|
200
580
|
slug: opts.slug,
|
|
201
|
-
isPublic:
|
|
581
|
+
isPublic: false,
|
|
582
|
+
expiresIn: opts.expires
|
|
202
583
|
});
|
|
203
584
|
console.log(`Created: ${result.url}`);
|
|
585
|
+
if (result.expiresAt) {
|
|
586
|
+
console.log(` Expires: ${new Date(result.expiresAt).toISOString()}`);
|
|
587
|
+
}
|
|
204
588
|
}
|
|
205
589
|
);
|
|
206
590
|
program.command("get").description("Get details of a publication").argument("<slug>", "Slug of the publication").option("--content", "Output raw content to stdout (no metadata, pipeable)").action(async (slug, opts) => {
|
|
@@ -214,11 +598,12 @@ program.command("get").description("Get details of a publication").argument("<sl
|
|
|
214
598
|
console.log(` Type: ${pub.contentType}`);
|
|
215
599
|
if (pub.title) console.log(` Title: ${pub.title}`);
|
|
216
600
|
console.log(` Status: ${formatVisibility(pub.isPublic)}`);
|
|
601
|
+
if (pub.expiresAt) console.log(` Expires: ${new Date(pub.expiresAt).toISOString()}`);
|
|
217
602
|
console.log(` Created: ${new Date(pub.createdAt).toLocaleDateString()}`);
|
|
218
603
|
console.log(` Updated: ${new Date(pub.updatedAt).toLocaleDateString()}`);
|
|
219
604
|
console.log(` Size: ${pub.content.length} bytes`);
|
|
220
605
|
});
|
|
221
|
-
program.command("update").description("Update a publication's content and/or metadata").argument("<slug>", "Slug of the publication to update").option("--file <file>", "New content from file").option("--title <title>", "New title").option("--
|
|
606
|
+
program.command("update").description("Update a publication's content and/or metadata").argument("<slug>", "Slug of the publication to update").option("--file <file>", "New content from file").option("--title <title>", "New title").option("--private", "Make the publication private").option("--slug <newSlug>", "Rename the slug").action(
|
|
222
607
|
async (slug, opts) => {
|
|
223
608
|
const client = createClient();
|
|
224
609
|
let content;
|
|
@@ -229,9 +614,15 @@ program.command("update").description("Update a publication's content and/or met
|
|
|
229
614
|
filename = file.basename;
|
|
230
615
|
}
|
|
231
616
|
let isPublic;
|
|
232
|
-
if (opts.
|
|
233
|
-
|
|
234
|
-
|
|
617
|
+
if (opts.private) isPublic = false;
|
|
618
|
+
const result = await client.update({
|
|
619
|
+
slug,
|
|
620
|
+
content,
|
|
621
|
+
filename,
|
|
622
|
+
title: opts.title,
|
|
623
|
+
isPublic,
|
|
624
|
+
newSlug: opts.slug
|
|
625
|
+
});
|
|
235
626
|
console.log(`Updated: ${result.slug}`);
|
|
236
627
|
if (result.title) console.log(` Title: ${result.title}`);
|
|
237
628
|
console.log(` Status: ${formatVisibility(result.isPublic)}`);
|
|
@@ -246,8 +637,9 @@ program.command("list").description("List your publications").action(async () =>
|
|
|
246
637
|
}
|
|
247
638
|
for (const pub of pubs) {
|
|
248
639
|
const date = new Date(pub.createdAt).toLocaleDateString();
|
|
640
|
+
const expires = pub.expiresAt ? ` expires:${new Date(pub.expiresAt).toISOString()}` : "";
|
|
249
641
|
console.log(
|
|
250
|
-
` ${pub.slug} [${pub.contentType}] ${formatVisibility(pub.isPublic)} ${date}`
|
|
642
|
+
` ${pub.slug} [${pub.contentType}] ${formatVisibility(pub.isPublic)} ${date}${expires}`
|
|
251
643
|
);
|
|
252
644
|
}
|
|
253
645
|
});
|
|
@@ -256,4 +648,5 @@ program.command("delete").description("Delete a publication").argument("<slug>",
|
|
|
256
648
|
await client.remove(slug);
|
|
257
649
|
console.log(`Deleted: ${slug}`);
|
|
258
650
|
});
|
|
651
|
+
registerTunnelCommands(program);
|
|
259
652
|
program.parse();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TunnelApiClient
|
|
3
|
+
} from "./chunk-BV423NLA.js";
|
|
4
|
+
import {
|
|
5
|
+
startDaemon
|
|
6
|
+
} from "./chunk-OLY5PC4A.js";
|
|
7
|
+
import "./chunk-56IKFMJ2.js";
|
|
8
|
+
|
|
9
|
+
// src/tunnel-daemon-entry.ts
|
|
10
|
+
var tunnelId = process.env.PUBBLUE_DAEMON_TUNNEL_ID;
|
|
11
|
+
var baseUrl = process.env.PUBBLUE_DAEMON_BASE_URL;
|
|
12
|
+
var apiKey = process.env.PUBBLUE_DAEMON_API_KEY;
|
|
13
|
+
var socketPath = process.env.PUBBLUE_DAEMON_SOCKET;
|
|
14
|
+
var infoPath = process.env.PUBBLUE_DAEMON_INFO;
|
|
15
|
+
if (!tunnelId || !baseUrl || !apiKey || !socketPath || !infoPath) {
|
|
16
|
+
console.error("Missing required env vars for daemon.");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
var apiClient = new TunnelApiClient(baseUrl, apiKey);
|
|
20
|
+
void startDaemon({ tunnelId, apiClient, socketPath, infoPath }).catch((error) => {
|
|
21
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
22
|
+
console.error(`Tunnel daemon failed to start: ${message}`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
});
|
package/package.json
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pubblue",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "CLI tool for publishing static content via pub.blue",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"pubblue": "./dist/index.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
11
|
-
"dev": "tsup src/index.ts --format esm --watch",
|
|
10
|
+
"build": "tsup src/index.ts src/tunnel-daemon-entry.ts --format esm --dts --clean",
|
|
11
|
+
"dev": "tsup src/index.ts src/tunnel-daemon-entry.ts --format esm --watch",
|
|
12
12
|
"test": "vitest run",
|
|
13
13
|
"test:watch": "vitest",
|
|
14
14
|
"lint": "tsc --noEmit"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"commander": "^13.0.0"
|
|
17
|
+
"commander": "^13.0.0",
|
|
18
|
+
"node-datachannel": "^0.32.0"
|
|
18
19
|
},
|
|
19
20
|
"devDependencies": {
|
|
20
21
|
"@types/node": "22.10.2",
|
|
@@ -35,7 +36,8 @@
|
|
|
35
36
|
},
|
|
36
37
|
"pnpm": {
|
|
37
38
|
"onlyBuiltDependencies": [
|
|
38
|
-
"esbuild"
|
|
39
|
+
"esbuild",
|
|
40
|
+
"node-datachannel"
|
|
39
41
|
]
|
|
40
42
|
}
|
|
41
43
|
}
|