coderemote 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -3
- package/dist/coderemote.js +848 -82
- package/package.json +4 -3
package/dist/coderemote.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// bin/coderemote.js
|
|
4
|
-
import { execFile as
|
|
4
|
+
import { execFile as execFile5, spawn as spawn3 } from "node:child_process";
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
|
-
import
|
|
7
|
-
import
|
|
6
|
+
import fs7 from "node:fs";
|
|
7
|
+
import os4 from "node:os";
|
|
8
8
|
import path6 from "node:path";
|
|
9
|
-
import { promisify as
|
|
9
|
+
import { promisify as promisify3 } from "node:util";
|
|
10
10
|
import qrcode from "qrcode-terminal";
|
|
11
11
|
|
|
12
12
|
// ../../packages/agent-core/src/server.js
|
|
13
13
|
import { createReadStream, createWriteStream } from "node:fs";
|
|
14
|
-
import
|
|
15
|
-
import
|
|
16
|
-
import
|
|
14
|
+
import fs4 from "node:fs/promises";
|
|
15
|
+
import http2 from "node:http";
|
|
16
|
+
import os3 from "node:os";
|
|
17
17
|
import path3 from "node:path";
|
|
18
18
|
import { pipeline } from "node:stream/promises";
|
|
19
19
|
import { timingSafeEqual as timingSafeEqual2 } from "node:crypto";
|
|
@@ -43,7 +43,18 @@ var Routes = Object.freeze({
|
|
|
43
43
|
gitShow: "GET /api/git/show",
|
|
44
44
|
gitPull: "POST /api/git/pull",
|
|
45
45
|
gitPush: "POST /api/git/push",
|
|
46
|
-
|
|
46
|
+
portsList: "GET /api/ports",
|
|
47
|
+
portsKill: "POST /api/ports/kill",
|
|
48
|
+
previewList: "GET /api/preview",
|
|
49
|
+
previewOpen: "POST /api/preview",
|
|
50
|
+
previewClose: "DELETE /api/preview",
|
|
51
|
+
previewShare: "POST /api/preview/share",
|
|
52
|
+
previewUnshare: "DELETE /api/preview/share",
|
|
53
|
+
previewRequests: "GET /api/preview/requests",
|
|
54
|
+
previewRequest: "GET /api/preview/request",
|
|
55
|
+
previewRequestsClear: "DELETE /api/preview/requests",
|
|
56
|
+
tunnelToken: "POST /api/tunnel/token",
|
|
57
|
+
tunnelStatus: "GET /api/tunnel"
|
|
47
58
|
});
|
|
48
59
|
var TUNNEL_HANDOFF_PATH = "/vsremote/handoff";
|
|
49
60
|
var TUNNEL_AUTH_CALLBACK_HOST = "tunnel-auth";
|
|
@@ -352,26 +363,328 @@ var LANDING_HTML = `<!doctype html>
|
|
|
352
363
|
<p id="missing" class="hidden">This link is missing its access token. Copy the full link printed by <code>coderemote up</code>.</p>
|
|
353
364
|
</main>
|
|
354
365
|
<script>
|
|
355
|
-
|
|
356
|
-
|
|
366
|
+
// Pasting the full link into a tab already showing this page (e.g. after the tunnel's sign-in
|
|
367
|
+
// dropped the fragment) only changes the hash and doesn't reload, so re-read it on hashchange.
|
|
368
|
+
function render() {
|
|
369
|
+
const token = new URLSearchParams(location.hash.slice(1)).get('token');
|
|
370
|
+
document.getElementById('ready').classList.toggle('hidden', !token);
|
|
371
|
+
document.getElementById('missing').classList.toggle('hidden', !!token);
|
|
372
|
+
if (!token) return;
|
|
357
373
|
const link = '${PAIRING_SCHEME}://pair?url=' + encodeURIComponent(location.origin) + '&token=' + encodeURIComponent(token);
|
|
358
374
|
document.getElementById('open').href = link;
|
|
375
|
+
document.getElementById('copy').textContent = 'Copy pairing link';
|
|
359
376
|
document.getElementById('copy').onclick = async (e) => {
|
|
360
377
|
try { await navigator.clipboard.writeText(link); e.target.textContent = 'Copied'; }
|
|
361
378
|
catch { prompt('Pairing link', link); }
|
|
362
379
|
};
|
|
363
|
-
document.getElementById('ready').classList.remove('hidden');
|
|
364
|
-
} else {
|
|
365
|
-
document.getElementById('missing').classList.remove('hidden');
|
|
366
380
|
}
|
|
381
|
+
render();
|
|
382
|
+
addEventListener('hashchange', render);
|
|
367
383
|
</script>
|
|
368
384
|
</body>
|
|
369
385
|
</html>
|
|
370
386
|
`;
|
|
371
387
|
|
|
388
|
+
// ../../packages/agent-core/src/ports.js
|
|
389
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
390
|
+
import fs3 from "node:fs/promises";
|
|
391
|
+
import os from "node:os";
|
|
392
|
+
function run(cmd2, args) {
|
|
393
|
+
return new Promise((resolve, reject) => {
|
|
394
|
+
execFile2(cmd2, args, { maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
|
|
395
|
+
if (err?.code === "ENOENT") reject(err);
|
|
396
|
+
else resolve(stdout || "");
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
function splitHostPort(name) {
|
|
401
|
+
const i = name.lastIndexOf(":");
|
|
402
|
+
const port2 = parseInt(name.slice(i + 1), 10);
|
|
403
|
+
if (i < 0 || !Number.isInteger(port2)) return null;
|
|
404
|
+
return { address: name.slice(0, i).replace(/^\[|\]$/g, ""), port: port2 };
|
|
405
|
+
}
|
|
406
|
+
async function listeningSockets() {
|
|
407
|
+
try {
|
|
408
|
+
const out2 = await run("lsof", ["-nP", "-a", "-u", String(os.userInfo().uid), "-iTCP", "-sTCP:LISTEN", "-F", "pcn"]);
|
|
409
|
+
const sockets2 = [];
|
|
410
|
+
let pid;
|
|
411
|
+
let name;
|
|
412
|
+
for (const line of out2.split("\n")) {
|
|
413
|
+
const field = line.slice(1);
|
|
414
|
+
if (line[0] === "p") pid = parseInt(field, 10);
|
|
415
|
+
else if (line[0] === "c") name = field;
|
|
416
|
+
else if (line[0] === "n") {
|
|
417
|
+
const hp = splitHostPort(field);
|
|
418
|
+
if (hp) sockets2.push({ pid, name, ...hp });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return sockets2;
|
|
422
|
+
} catch (e) {
|
|
423
|
+
if (e.code !== "ENOENT" || os.platform() !== "linux") throw e;
|
|
424
|
+
}
|
|
425
|
+
const out = await run("ss", ["-Hltnp"]);
|
|
426
|
+
const sockets = [];
|
|
427
|
+
for (const line of out.split("\n")) {
|
|
428
|
+
const cols = line.trim().split(/\s+/);
|
|
429
|
+
const hp = cols[3] && splitHostPort(cols[3]);
|
|
430
|
+
if (!hp) continue;
|
|
431
|
+
for (const m of line.matchAll(/\("([^"]*)",pid=(\d+)/g)) sockets.push({ pid: parseInt(m[2], 10), name: m[1], ...hp });
|
|
432
|
+
}
|
|
433
|
+
return sockets;
|
|
434
|
+
}
|
|
435
|
+
async function processTable() {
|
|
436
|
+
const out = await run("ps", ["-A", "-ww", "-o", "pid=,ppid=,command="]);
|
|
437
|
+
const table = /* @__PURE__ */ new Map();
|
|
438
|
+
for (const line of out.split("\n")) {
|
|
439
|
+
const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
|
|
440
|
+
if (m) table.set(parseInt(m[1], 10), { ppid: parseInt(m[2], 10), command: m[3] });
|
|
441
|
+
}
|
|
442
|
+
return table;
|
|
443
|
+
}
|
|
444
|
+
async function workingDirs(pids) {
|
|
445
|
+
const dirs = /* @__PURE__ */ new Map();
|
|
446
|
+
if (!pids.length) return dirs;
|
|
447
|
+
if (os.platform() === "linux") {
|
|
448
|
+
await Promise.all(pids.map(async (pid2) => dirs.set(pid2, await fs3.readlink(`/proc/${pid2}/cwd`).catch(() => null))));
|
|
449
|
+
return dirs;
|
|
450
|
+
}
|
|
451
|
+
const out = await run("lsof", ["-a", "-p", pids.join(","), "-d", "cwd", "-F", "pn"]).catch(() => "");
|
|
452
|
+
let pid;
|
|
453
|
+
for (const line of out.split("\n")) {
|
|
454
|
+
if (line[0] === "p") pid = parseInt(line.slice(1), 10);
|
|
455
|
+
else if (line[0] === "n") dirs.set(pid, line.slice(1));
|
|
456
|
+
}
|
|
457
|
+
return dirs;
|
|
458
|
+
}
|
|
459
|
+
var AGENT_COMMAND = /(^|[\/\s])coderemote(\.js)?\s+up(\s|$)/;
|
|
460
|
+
async function listPorts(shells = /* @__PURE__ */ new Map()) {
|
|
461
|
+
const [sockets, table] = await Promise.all([listeningSockets(), processTable()]);
|
|
462
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
463
|
+
for (const s of sockets) {
|
|
464
|
+
const key = `${s.pid}:${s.port}`;
|
|
465
|
+
const entry2 = byKey.get(key);
|
|
466
|
+
if (entry2) {
|
|
467
|
+
if (!entry2.addresses.includes(s.address)) entry2.addresses.push(s.address);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
byKey.set(key, {
|
|
471
|
+
port: s.port,
|
|
472
|
+
addresses: [s.address],
|
|
473
|
+
pid: s.pid,
|
|
474
|
+
name: s.name,
|
|
475
|
+
command: table.get(s.pid)?.command ?? s.name,
|
|
476
|
+
cwd: null,
|
|
477
|
+
session: sessionOf(s.pid, table, shells),
|
|
478
|
+
self: s.pid === process.pid,
|
|
479
|
+
agent: s.pid === process.pid || AGENT_COMMAND.test(table.get(s.pid)?.command ?? "")
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
const entries = [...byKey.values()];
|
|
483
|
+
const dirs = await workingDirs([...new Set(entries.map((e) => e.pid))]);
|
|
484
|
+
for (const e of entries) e.cwd = dirs.get(e.pid) ?? null;
|
|
485
|
+
return entries.sort((a, b) => a.port - b.port || a.pid - b.pid);
|
|
486
|
+
}
|
|
487
|
+
function sessionOf(pid, table, shells) {
|
|
488
|
+
for (let p = pid, depth = 0; p > 1 && depth < 64; p = table.get(p)?.ppid, depth++) {
|
|
489
|
+
if (shells.has(p)) return shells.get(p);
|
|
490
|
+
}
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
async function killListener(pid, { force = false, shells } = {}) {
|
|
494
|
+
if (!Number.isInteger(pid) || pid <= 1) throw new HttpError(400, "pid must be a process id");
|
|
495
|
+
if (pid === process.pid) throw new HttpError(403, "the agent cannot stop itself");
|
|
496
|
+
const ports = await listPorts(shells);
|
|
497
|
+
if (!ports.some((p) => p.pid === pid)) throw new HttpError(404, "no listening process with that pid");
|
|
498
|
+
try {
|
|
499
|
+
process.kill(pid, force ? "SIGKILL" : "SIGTERM");
|
|
500
|
+
} catch (e) {
|
|
501
|
+
if (e.code === "ESRCH") throw new HttpError(404, "no listening process with that pid");
|
|
502
|
+
throw e;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ../../packages/agent-core/src/inspector.js
|
|
507
|
+
import http from "node:http";
|
|
508
|
+
import net from "node:net";
|
|
509
|
+
import zlib from "node:zlib";
|
|
510
|
+
var MAX_ENTRIES = 200;
|
|
511
|
+
var MAX_BODY = 64 * 1024;
|
|
512
|
+
var TEXTUAL = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|graphql|[\w.+-]+\+(json|xml)))/i;
|
|
513
|
+
var nextId = 1;
|
|
514
|
+
var BodyTap = class {
|
|
515
|
+
chunks = [];
|
|
516
|
+
kept = 0;
|
|
517
|
+
size = 0;
|
|
518
|
+
push(chunk) {
|
|
519
|
+
this.size += chunk.length;
|
|
520
|
+
if (this.kept < MAX_BODY) {
|
|
521
|
+
const part = chunk.subarray(0, MAX_BODY - this.kept);
|
|
522
|
+
this.chunks.push(part);
|
|
523
|
+
this.kept += part.length;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
/** { size, truncated, text? | base64? }, decoded when the whole body was kept. */
|
|
527
|
+
describe(headers) {
|
|
528
|
+
const body = { size: this.size, truncated: this.size > this.kept };
|
|
529
|
+
if (!this.size || body.truncated) return body;
|
|
530
|
+
let data = Buffer.concat(this.chunks);
|
|
531
|
+
const encoding = String(headers["content-encoding"] || "").toLowerCase();
|
|
532
|
+
try {
|
|
533
|
+
if (encoding === "gzip") data = zlib.gunzipSync(data);
|
|
534
|
+
else if (encoding === "br") data = zlib.brotliDecompressSync(data);
|
|
535
|
+
else if (encoding === "deflate") data = zlib.inflateSync(data);
|
|
536
|
+
} catch {
|
|
537
|
+
return body;
|
|
538
|
+
}
|
|
539
|
+
if (TEXTUAL.test(String(headers["content-type"] || ""))) body.text = data.toString("utf8");
|
|
540
|
+
else body.base64 = data.toString("base64");
|
|
541
|
+
return body;
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
var InspectingProxy = class {
|
|
545
|
+
/** @type {object[]} */
|
|
546
|
+
#entries = [];
|
|
547
|
+
/** @param {number} target the dev server's port on this machine */
|
|
548
|
+
constructor(target) {
|
|
549
|
+
this.target = target;
|
|
550
|
+
this.server = http.createServer((req, res) => this.#forward(req, res));
|
|
551
|
+
this.server.on("upgrade", (req, socket, head) => this.#tunnelUpgrade(req, socket, head));
|
|
552
|
+
}
|
|
553
|
+
/** Listens on a free loopback port; resolves to it. */
|
|
554
|
+
async listen() {
|
|
555
|
+
await new Promise((resolve, reject) => {
|
|
556
|
+
this.server.once("error", reject);
|
|
557
|
+
this.server.listen(0, "127.0.0.1", resolve);
|
|
558
|
+
});
|
|
559
|
+
this.port = this.server.address().port;
|
|
560
|
+
return this.port;
|
|
561
|
+
}
|
|
562
|
+
close() {
|
|
563
|
+
this.server.closeAllConnections?.();
|
|
564
|
+
this.server.close();
|
|
565
|
+
}
|
|
566
|
+
/** Summaries, oldest first; `after` skips ids up to and including it. */
|
|
567
|
+
list(after = 0) {
|
|
568
|
+
return this.#entries.filter((e) => e.id > after).map(summary);
|
|
569
|
+
}
|
|
570
|
+
get(id) {
|
|
571
|
+
return this.#entries.find((e) => e.id === id);
|
|
572
|
+
}
|
|
573
|
+
clear() {
|
|
574
|
+
this.#entries = [];
|
|
575
|
+
}
|
|
576
|
+
#record(req) {
|
|
577
|
+
const entry2 = {
|
|
578
|
+
id: nextId++,
|
|
579
|
+
time: Date.now(),
|
|
580
|
+
method: req.method,
|
|
581
|
+
path: req.url,
|
|
582
|
+
status: null,
|
|
583
|
+
duration: null,
|
|
584
|
+
error: null,
|
|
585
|
+
request: { headers: req.headers, tap: new BodyTap() },
|
|
586
|
+
response: { headers: {}, tap: new BodyTap() }
|
|
587
|
+
};
|
|
588
|
+
this.#entries.push(entry2);
|
|
589
|
+
if (this.#entries.length > MAX_ENTRIES) this.#entries.shift();
|
|
590
|
+
return entry2;
|
|
591
|
+
}
|
|
592
|
+
/** The dev server sees itself addressed as localhost:<its port>, as without the proxy. */
|
|
593
|
+
#headers(req) {
|
|
594
|
+
return { ...req.headers, host: `localhost:${this.target}` };
|
|
595
|
+
}
|
|
596
|
+
#forward(req, res) {
|
|
597
|
+
const entry2 = this.#record(req);
|
|
598
|
+
const started = performance.now();
|
|
599
|
+
const finish = () => {
|
|
600
|
+
entry2.duration = Math.round(performance.now() - started);
|
|
601
|
+
};
|
|
602
|
+
const upstream = http.request({
|
|
603
|
+
agent: false,
|
|
604
|
+
host: "localhost",
|
|
605
|
+
port: this.target,
|
|
606
|
+
method: req.method,
|
|
607
|
+
path: req.url,
|
|
608
|
+
headers: this.#headers(req)
|
|
609
|
+
});
|
|
610
|
+
upstream.on("response", (up) => {
|
|
611
|
+
entry2.status = up.statusCode;
|
|
612
|
+
entry2.response.headers = up.headers;
|
|
613
|
+
res.writeHead(up.statusCode, up.statusMessage, up.rawHeaders);
|
|
614
|
+
up.on("data", (chunk) => {
|
|
615
|
+
entry2.response.tap.push(chunk);
|
|
616
|
+
res.write(chunk);
|
|
617
|
+
});
|
|
618
|
+
up.on("end", () => {
|
|
619
|
+
finish();
|
|
620
|
+
res.end();
|
|
621
|
+
});
|
|
622
|
+
up.on("error", () => res.destroy());
|
|
623
|
+
});
|
|
624
|
+
upstream.on("error", (e) => {
|
|
625
|
+
finish();
|
|
626
|
+
entry2.error = e.code === "ECONNREFUSED" ? `nothing is listening on port ${this.target}` : e.message;
|
|
627
|
+
if (res.headersSent) return res.destroy();
|
|
628
|
+
entry2.status = 502;
|
|
629
|
+
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
|
630
|
+
res.end(`coderemote preview: ${entry2.error}
|
|
631
|
+
`);
|
|
632
|
+
});
|
|
633
|
+
req.on("data", (chunk) => entry2.request.tap.push(chunk));
|
|
634
|
+
req.pipe(upstream);
|
|
635
|
+
res.on("close", () => {
|
|
636
|
+
if (!res.writableFinished) upstream.destroy();
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
/** WebSockets (hot reload) pass through as raw bytes; only the handshake is recorded. */
|
|
640
|
+
#tunnelUpgrade(req, socket, head) {
|
|
641
|
+
const entry2 = this.#record(req);
|
|
642
|
+
const upstream = net.connect({ host: "localhost", port: this.target }, () => {
|
|
643
|
+
const headers = this.#headers(req);
|
|
644
|
+
const lines = [`${req.method} ${req.url} HTTP/1.1`];
|
|
645
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
646
|
+
for (const value of Array.isArray(v) ? v : [v]) lines.push(`${k}: ${value}`);
|
|
647
|
+
}
|
|
648
|
+
upstream.write(`${lines.join("\r\n")}\r
|
|
649
|
+
\r
|
|
650
|
+
`);
|
|
651
|
+
if (head?.length) upstream.write(head);
|
|
652
|
+
entry2.status = 101;
|
|
653
|
+
upstream.pipe(socket);
|
|
654
|
+
socket.pipe(upstream);
|
|
655
|
+
});
|
|
656
|
+
upstream.on("error", (e) => {
|
|
657
|
+
entry2.error = e.message;
|
|
658
|
+
socket.destroy();
|
|
659
|
+
});
|
|
660
|
+
socket.on("error", () => upstream.destroy());
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
function summary(e) {
|
|
664
|
+
return {
|
|
665
|
+
id: e.id,
|
|
666
|
+
time: e.time,
|
|
667
|
+
method: e.method,
|
|
668
|
+
path: e.path,
|
|
669
|
+
status: e.status,
|
|
670
|
+
duration: e.duration,
|
|
671
|
+
error: e.error,
|
|
672
|
+
contentType: e.response.headers["content-type"] ?? null,
|
|
673
|
+
requestSize: e.request.tap.size,
|
|
674
|
+
responseSize: e.response.tap.size
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
function detail(e) {
|
|
678
|
+
return {
|
|
679
|
+
...summary(e),
|
|
680
|
+
request: { headers: e.request.headers, body: e.request.tap.describe(e.request.headers) },
|
|
681
|
+
response: { headers: e.response.headers, body: e.response.tap.describe(e.response.headers) }
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
372
685
|
// ../../packages/agent-core/src/sessions.js
|
|
373
686
|
import { randomUUID } from "node:crypto";
|
|
374
|
-
import
|
|
687
|
+
import os2 from "node:os";
|
|
375
688
|
import pty from "node-pty";
|
|
376
689
|
var SCROLLBACK_LIMIT = 256 * 1024;
|
|
377
690
|
var TerminalSession = class {
|
|
@@ -382,13 +695,13 @@ var TerminalSession = class {
|
|
|
382
695
|
this.bufferedBytes = 0;
|
|
383
696
|
this.clients = /* @__PURE__ */ new Set();
|
|
384
697
|
this.exitCode = null;
|
|
385
|
-
const shell = process.env.SHELL || (
|
|
698
|
+
const shell = process.env.SHELL || (os2.platform() === "win32" ? "powershell.exe" : "/bin/sh");
|
|
386
699
|
this.shell = shell;
|
|
387
700
|
this.pty = pty.spawn(shell, ["-l"], {
|
|
388
701
|
name: "xterm-256color",
|
|
389
702
|
cols,
|
|
390
703
|
rows,
|
|
391
|
-
cwd: cwd ||
|
|
704
|
+
cwd: cwd || os2.homedir(),
|
|
392
705
|
env: { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" }
|
|
393
706
|
});
|
|
394
707
|
this.pty.onData((data) => this.#output(Buffer.from(data, "utf8")));
|
|
@@ -476,11 +789,11 @@ function kindOf(stat) {
|
|
|
476
789
|
return "other";
|
|
477
790
|
}
|
|
478
791
|
async function describe(fullPath, name) {
|
|
479
|
-
const lst = await
|
|
792
|
+
const lst = await fs4.lstat(fullPath);
|
|
480
793
|
const entry2 = { name, type: kindOf(lst), size: lst.size, mtime: lst.mtimeMs };
|
|
481
794
|
if (lst.isSymbolicLink()) {
|
|
482
795
|
try {
|
|
483
|
-
entry2.target = kindOf(await
|
|
796
|
+
entry2.target = kindOf(await fs4.stat(fullPath));
|
|
484
797
|
} catch {
|
|
485
798
|
entry2.target = "broken";
|
|
486
799
|
}
|
|
@@ -501,7 +814,7 @@ async function readJson(req) {
|
|
|
501
814
|
throw new HttpError(400, "invalid json");
|
|
502
815
|
}
|
|
503
816
|
}
|
|
504
|
-
function createAgentServer({ token, root: root2, tunnelToken = async () => null }) {
|
|
817
|
+
function createAgentServer({ token, root: root2, tunnelToken = async () => null, preview = null, tunnelStatus: tunnelStatus2 = null }) {
|
|
505
818
|
const handoff = new TunnelHandoff({ token, mint: tunnelToken });
|
|
506
819
|
const sandbox = new Sandbox(root2);
|
|
507
820
|
const sessions = new SessionManager();
|
|
@@ -510,10 +823,45 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null
|
|
|
510
823
|
const got = Buffer.from(req.headers.authorization || "");
|
|
511
824
|
return got.length === expected.length && timingSafeEqual2(got, expected);
|
|
512
825
|
}
|
|
826
|
+
function previewPort(value, ownPort) {
|
|
827
|
+
if (!preview) throw new HttpError(404, "previews need a tunnel");
|
|
828
|
+
const port2 = Number(value);
|
|
829
|
+
if (!Number.isInteger(port2) || port2 < 1 || port2 > 65535) throw new HttpError(400, "port must be 1-65535");
|
|
830
|
+
if (port2 === ownPort) throw new HttpError(400, "the agent's own port is already on its tunnel");
|
|
831
|
+
if ([...proxies.values()].some((p) => p.port === port2)) throw new HttpError(400, "that port is a preview proxy");
|
|
832
|
+
return port2;
|
|
833
|
+
}
|
|
834
|
+
const proxies = /* @__PURE__ */ new Map();
|
|
835
|
+
function previewed(port2) {
|
|
836
|
+
const proxy = proxies.get(port2);
|
|
837
|
+
if (!proxy || !preview.list().some((p) => p.port === proxy.port)) {
|
|
838
|
+
throw new HttpError(404, "that port is not being previewed");
|
|
839
|
+
}
|
|
840
|
+
return proxy;
|
|
841
|
+
}
|
|
842
|
+
function inspected(value) {
|
|
843
|
+
const proxy = proxies.get(previewPort(value, server.address()?.port));
|
|
844
|
+
if (!proxy) throw new HttpError(404, "that port has not been previewed");
|
|
845
|
+
return proxy;
|
|
846
|
+
}
|
|
847
|
+
async function previews() {
|
|
848
|
+
const issued = await preview.token();
|
|
849
|
+
const forwarded = new Map(preview.list().map((p) => [p.port, p]));
|
|
850
|
+
const ports = [];
|
|
851
|
+
for (const [port2, proxy] of proxies) {
|
|
852
|
+
const p = forwarded.get(proxy.port);
|
|
853
|
+
if (p) ports.push({ ...p, port: port2 });
|
|
854
|
+
}
|
|
855
|
+
ports.sort((a, b) => a.port - b.port);
|
|
856
|
+
return { ports, token: issued?.token ?? null, expiresAt: issued?.expiresAt ?? null };
|
|
857
|
+
}
|
|
858
|
+
function shellPids() {
|
|
859
|
+
return new Map(sessions.list().map((s) => [s.pid, s.id]));
|
|
860
|
+
}
|
|
513
861
|
async function repoOf(p) {
|
|
514
862
|
const repo = await sandbox.resolve(p);
|
|
515
863
|
try {
|
|
516
|
-
await
|
|
864
|
+
await fs4.access(path3.join(repo, ".git"));
|
|
517
865
|
} catch {
|
|
518
866
|
throw new HttpError(400, "not a git repository");
|
|
519
867
|
}
|
|
@@ -535,15 +883,15 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null
|
|
|
535
883
|
const routes = {
|
|
536
884
|
[Routes.info]: async () => ({
|
|
537
885
|
protocol: PROTOCOL_VERSION,
|
|
538
|
-
hostname:
|
|
539
|
-
platform:
|
|
886
|
+
hostname: os3.hostname(),
|
|
887
|
+
platform: os3.platform(),
|
|
540
888
|
root: sandbox.root,
|
|
541
|
-
user:
|
|
889
|
+
user: os3.userInfo().username,
|
|
542
890
|
shell: process.env.SHELL || null
|
|
543
891
|
}),
|
|
544
892
|
[Routes.fsList]: async (url) => {
|
|
545
893
|
const dir = await sandbox.resolve(url.searchParams.get("path"));
|
|
546
|
-
const names = await
|
|
894
|
+
const names = await fs4.readdir(dir);
|
|
547
895
|
const entries = (await Promise.all(names.map((n) => describe(path3.join(dir, n), n).catch(() => null)))).filter(Boolean);
|
|
548
896
|
return { path: dir, entries };
|
|
549
897
|
},
|
|
@@ -553,7 +901,7 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null
|
|
|
553
901
|
},
|
|
554
902
|
[Routes.fsRead]: async (url, req, res) => {
|
|
555
903
|
const p = await sandbox.resolve(url.searchParams.get("path"));
|
|
556
|
-
const stat = await
|
|
904
|
+
const stat = await fs4.stat(p);
|
|
557
905
|
if (!stat.isFile()) throw new HttpError(400, "not a file");
|
|
558
906
|
res.writeHead(200, { "content-type": "application/octet-stream", "content-length": stat.size });
|
|
559
907
|
await pipeline(createReadStream(p), res);
|
|
@@ -563,24 +911,24 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null
|
|
|
563
911
|
const tmp = `${p}.vsremote-${process.pid}-${Date.now()}.tmp`;
|
|
564
912
|
try {
|
|
565
913
|
await pipeline(req, createWriteStream(tmp));
|
|
566
|
-
await
|
|
914
|
+
await fs4.rename(tmp, p);
|
|
567
915
|
} catch (e) {
|
|
568
|
-
await
|
|
916
|
+
await fs4.rm(tmp, { force: true });
|
|
569
917
|
throw e;
|
|
570
918
|
}
|
|
571
919
|
return describe(p, path3.basename(p));
|
|
572
920
|
},
|
|
573
921
|
[Routes.fsMkdir]: async (url) => {
|
|
574
|
-
await
|
|
922
|
+
await fs4.mkdir(await sandbox.resolve(url.searchParams.get("path"), { allowMissing: true }), { recursive: true });
|
|
575
923
|
return {};
|
|
576
924
|
},
|
|
577
925
|
[Routes.fsRename]: async (url, req) => {
|
|
578
926
|
const { from, to } = await readJson(req);
|
|
579
|
-
await
|
|
927
|
+
await fs4.rename(await sandbox.resolveChild(from), await sandbox.resolveChild(to, { allowMissing: true }));
|
|
580
928
|
return {};
|
|
581
929
|
},
|
|
582
930
|
[Routes.fsDelete]: async (url) => {
|
|
583
|
-
await
|
|
931
|
+
await fs4.rm(await sandbox.resolveChild(url.searchParams.get("path")), { recursive: true });
|
|
584
932
|
return {};
|
|
585
933
|
},
|
|
586
934
|
[Routes.gitRepos]: async () => ({ repos: await findRepos(sandbox.root) }),
|
|
@@ -622,6 +970,74 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null
|
|
|
622
970
|
[Routes.gitPull]: async (url, req) => ({ output: await pull(await repoOf((await readJson(req)).repo)) }),
|
|
623
971
|
[Routes.gitPush]: async (url, req) => ({ output: await push(await repoOf((await readJson(req)).repo)) }),
|
|
624
972
|
[Routes.termList]: async () => ({ sessions: sessions.list() }),
|
|
973
|
+
[Routes.portsList]: async () => ({ ports: await listPorts(shellPids()) }),
|
|
974
|
+
[Routes.portsKill]: async (url, req) => {
|
|
975
|
+
const { pid, force } = await readJson(req);
|
|
976
|
+
await killListener(pid, { force: force === true, shells: shellPids() });
|
|
977
|
+
return {};
|
|
978
|
+
},
|
|
979
|
+
[Routes.previewList]: async () => {
|
|
980
|
+
if (!preview) throw new HttpError(404, "previews need a tunnel");
|
|
981
|
+
return previews();
|
|
982
|
+
},
|
|
983
|
+
[Routes.previewOpen]: async (url, req) => {
|
|
984
|
+
const port2 = previewPort((await readJson(req)).port, server.address()?.port);
|
|
985
|
+
let proxy = proxies.get(port2);
|
|
986
|
+
if (!proxy) {
|
|
987
|
+
proxy = new InspectingProxy(port2);
|
|
988
|
+
await proxy.listen();
|
|
989
|
+
proxies.set(port2, proxy);
|
|
990
|
+
}
|
|
991
|
+
try {
|
|
992
|
+
await preview.forward(proxy.port);
|
|
993
|
+
} catch (e) {
|
|
994
|
+
throw new HttpError(502, e.message);
|
|
995
|
+
}
|
|
996
|
+
return previews();
|
|
997
|
+
},
|
|
998
|
+
[Routes.previewClose]: async (url) => {
|
|
999
|
+
const port2 = previewPort(url.searchParams.get("port"), server.address()?.port);
|
|
1000
|
+
const proxy = proxies.get(port2);
|
|
1001
|
+
if (proxy) await preview.unforward(proxy.port);
|
|
1002
|
+
return previews();
|
|
1003
|
+
},
|
|
1004
|
+
[Routes.previewShare]: async (url, req) => {
|
|
1005
|
+
const { port: value, hours } = await readJson(req);
|
|
1006
|
+
const proxy = previewed(previewPort(value, server.address()?.port));
|
|
1007
|
+
if (!Number.isInteger(hours) || hours < 1 || hours > 720) throw new HttpError(400, "hours must be 1-720");
|
|
1008
|
+
try {
|
|
1009
|
+
await preview.share(proxy.port, hours);
|
|
1010
|
+
} catch (e) {
|
|
1011
|
+
throw new HttpError(502, e.message);
|
|
1012
|
+
}
|
|
1013
|
+
return previews();
|
|
1014
|
+
},
|
|
1015
|
+
[Routes.previewUnshare]: async (url) => {
|
|
1016
|
+
const proxy = proxies.get(previewPort(url.searchParams.get("port"), server.address()?.port));
|
|
1017
|
+
try {
|
|
1018
|
+
if (proxy) await preview.unshare(proxy.port);
|
|
1019
|
+
} catch (e) {
|
|
1020
|
+
throw new HttpError(502, e.message);
|
|
1021
|
+
}
|
|
1022
|
+
return previews();
|
|
1023
|
+
},
|
|
1024
|
+
[Routes.previewRequests]: async (url) => ({
|
|
1025
|
+
requests: inspected(url.searchParams.get("port")).list(parseInt(url.searchParams.get("after"), 10) || 0)
|
|
1026
|
+
}),
|
|
1027
|
+
[Routes.previewRequest]: async (url) => {
|
|
1028
|
+
const entry2 = inspected(url.searchParams.get("port")).get(parseInt(url.searchParams.get("id"), 10));
|
|
1029
|
+
if (!entry2) throw new HttpError(404, "no such request (only the latest 200 are kept)");
|
|
1030
|
+
return detail(entry2);
|
|
1031
|
+
},
|
|
1032
|
+
[Routes.previewRequestsClear]: async (url) => {
|
|
1033
|
+
inspected(url.searchParams.get("port")).clear();
|
|
1034
|
+
return {};
|
|
1035
|
+
},
|
|
1036
|
+
[Routes.tunnelStatus]: async () => {
|
|
1037
|
+
const status2 = await tunnelStatus2?.();
|
|
1038
|
+
if (!status2) throw new HttpError(404, "this agent runs without a tunnel");
|
|
1039
|
+
return status2;
|
|
1040
|
+
},
|
|
625
1041
|
[Routes.tunnelToken]: async () => {
|
|
626
1042
|
const issued = await handoff.issue();
|
|
627
1043
|
if (!issued) throw new HttpError(404, "this tunnel is anonymous");
|
|
@@ -634,7 +1050,7 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null
|
|
|
634
1050
|
return {};
|
|
635
1051
|
}
|
|
636
1052
|
};
|
|
637
|
-
const server =
|
|
1053
|
+
const server = http2.createServer(async (req, res) => {
|
|
638
1054
|
const url = new URL(req.url, "http://localhost");
|
|
639
1055
|
if (req.method === "GET" && url.pathname === "/") {
|
|
640
1056
|
res.writeHead(200, {
|
|
@@ -708,18 +1124,21 @@ function createAgentServer({ token, root: root2, tunnelToken = async () => null
|
|
|
708
1124
|
}
|
|
709
1125
|
wss.handleUpgrade(req, socket, head, (ws) => session.attach(ws));
|
|
710
1126
|
});
|
|
711
|
-
server.on("close", () =>
|
|
1127
|
+
server.on("close", () => {
|
|
1128
|
+
sessions.killAll();
|
|
1129
|
+
for (const proxy of proxies.values()) proxy.close();
|
|
1130
|
+
});
|
|
712
1131
|
server.killAllSessions = () => sessions.killAll();
|
|
713
1132
|
return server;
|
|
714
1133
|
}
|
|
715
1134
|
|
|
716
1135
|
// src/devtunnel.js
|
|
717
|
-
import { execFile as
|
|
1136
|
+
import { execFile as execFile3 } from "node:child_process";
|
|
718
1137
|
import { createHash } from "node:crypto";
|
|
719
|
-
import
|
|
1138
|
+
import fs5 from "node:fs";
|
|
720
1139
|
import path4 from "node:path";
|
|
721
1140
|
import { promisify } from "node:util";
|
|
722
|
-
var
|
|
1141
|
+
var run2 = promisify(execFile3);
|
|
723
1142
|
var DOWNLOAD_BASE = "https://tunnelsassetsprod.blob.core.windows.net/cli";
|
|
724
1143
|
var BUILDS = {
|
|
725
1144
|
"darwin-arm64": "osx-arm64-devtunnel",
|
|
@@ -731,8 +1150,8 @@ var MICROSOFT_TEAM_ID = "UBF8T346G9";
|
|
|
731
1150
|
var REFRESH_INTERVAL_MS = 24 * 36e5;
|
|
732
1151
|
function executable(file) {
|
|
733
1152
|
try {
|
|
734
|
-
|
|
735
|
-
return
|
|
1153
|
+
fs5.accessSync(file, fs5.constants.X_OK);
|
|
1154
|
+
return fs5.statSync(file).isFile();
|
|
736
1155
|
} catch {
|
|
737
1156
|
return false;
|
|
738
1157
|
}
|
|
@@ -761,7 +1180,7 @@ async function ensureDevtunnel({ dir, progress }) {
|
|
|
761
1180
|
const metaFile = path4.join(dir, "devtunnel.json");
|
|
762
1181
|
let meta = {};
|
|
763
1182
|
try {
|
|
764
|
-
meta = JSON.parse(
|
|
1183
|
+
meta = JSON.parse(fs5.readFileSync(metaFile, "utf8"));
|
|
765
1184
|
} catch {
|
|
766
1185
|
}
|
|
767
1186
|
const installed = executable(bin);
|
|
@@ -772,7 +1191,7 @@ async function ensureDevtunnel({ dir, progress }) {
|
|
|
772
1191
|
const head = await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(3e3) });
|
|
773
1192
|
const lastModified = head.headers.get("last-modified");
|
|
774
1193
|
if (head.ok && lastModified && lastModified === meta.lastModified) {
|
|
775
|
-
|
|
1194
|
+
fs5.writeFileSync(metaFile, JSON.stringify({ ...meta, checkedAt: Date.now() }));
|
|
776
1195
|
return bin;
|
|
777
1196
|
}
|
|
778
1197
|
} catch {
|
|
@@ -783,12 +1202,12 @@ async function ensureDevtunnel({ dir, progress }) {
|
|
|
783
1202
|
return bin;
|
|
784
1203
|
}
|
|
785
1204
|
async function download(url, bin, metaFile, progress) {
|
|
786
|
-
|
|
1205
|
+
fs5.mkdirSync(path4.dirname(bin), { recursive: true, mode: 448 });
|
|
787
1206
|
const res = await fetch(url, { signal: AbortSignal.timeout(12e4) });
|
|
788
1207
|
if (!res.ok || !res.body) throw new Error(`Downloading devtunnel failed: HTTP ${res.status} from ${url}`);
|
|
789
1208
|
const total = Number(res.headers.get("content-length")) || 0;
|
|
790
1209
|
const tmp = `${bin}.${process.pid}.download`;
|
|
791
|
-
const out =
|
|
1210
|
+
const out = fs5.createWriteStream(tmp, { mode: 493 });
|
|
792
1211
|
const md5 = createHash("md5");
|
|
793
1212
|
let received = 0;
|
|
794
1213
|
try {
|
|
@@ -801,41 +1220,224 @@ async function download(url, bin, metaFile, progress) {
|
|
|
801
1220
|
await new Promise((resolve, reject) => out.end((e) => e ? reject(e) : resolve()));
|
|
802
1221
|
if (total && received !== total) throw new Error(`devtunnel download was cut short (${received} of ${total} bytes)`);
|
|
803
1222
|
await verify(tmp, md5.digest("base64"), res.headers.get("content-md5"));
|
|
804
|
-
|
|
805
|
-
|
|
1223
|
+
fs5.chmodSync(tmp, 493);
|
|
1224
|
+
fs5.renameSync(tmp, bin);
|
|
806
1225
|
} catch (e) {
|
|
807
1226
|
out.destroy();
|
|
808
|
-
|
|
1227
|
+
fs5.rmSync(tmp, { force: true });
|
|
809
1228
|
throw e;
|
|
810
1229
|
}
|
|
811
|
-
|
|
1230
|
+
fs5.writeFileSync(metaFile, JSON.stringify({ lastModified: res.headers.get("last-modified"), checkedAt: Date.now() }));
|
|
812
1231
|
}
|
|
813
1232
|
async function verify(file, md5, expectedMd5) {
|
|
814
1233
|
if (expectedMd5 && md5 !== expectedMd5) throw new Error("devtunnel download is corrupt (MD5 mismatch)");
|
|
815
1234
|
if (process.platform !== "darwin") return;
|
|
816
1235
|
try {
|
|
817
|
-
await
|
|
818
|
-
const { stderr } = await
|
|
1236
|
+
await run2("codesign", ["--verify", "--strict", file]);
|
|
1237
|
+
const { stderr } = await run2("codesign", ["-dv", file]);
|
|
819
1238
|
if (!stderr.includes(`TeamIdentifier=${MICROSOFT_TEAM_ID}`)) throw new Error("not signed by Microsoft");
|
|
820
1239
|
} catch (e) {
|
|
821
1240
|
throw new Error(`devtunnel download failed its signature check: ${e.message}`);
|
|
822
1241
|
}
|
|
823
1242
|
}
|
|
824
1243
|
|
|
1244
|
+
// src/preview.js
|
|
1245
|
+
import { spawn } from "node:child_process";
|
|
1246
|
+
var HOST_READY_TIMEOUT_MS = 3e4;
|
|
1247
|
+
var TOKEN_REUSE_MS = 12 * 36e5;
|
|
1248
|
+
var PreviewTunnel = class {
|
|
1249
|
+
#queue = Promise.resolve();
|
|
1250
|
+
#tunnelId = null;
|
|
1251
|
+
/** @type {{ child: import('node:child_process').ChildProcess, urls: Map<number, string> } | null} */
|
|
1252
|
+
#host = null;
|
|
1253
|
+
#ports = /* @__PURE__ */ new Set();
|
|
1254
|
+
/** port -> ms since the epoch when its public access ends */
|
|
1255
|
+
#shares = /* @__PURE__ */ new Map();
|
|
1256
|
+
#token = null;
|
|
1257
|
+
/**
|
|
1258
|
+
* @param {object} opts
|
|
1259
|
+
* @param {(...args: string[]) => Promise<{ok: boolean, out: string}>} opts.devtunnel runs the CLI
|
|
1260
|
+
* @param {() => string} opts.command the devtunnel executable, for `devtunnel host`
|
|
1261
|
+
* @param {string} opts.description
|
|
1262
|
+
* @param {string | undefined} opts.leftover a preview tunnel from an earlier run, deleted on first use
|
|
1263
|
+
* @param {(tunnelId: string | null) => void} opts.remember persists the current tunnel id
|
|
1264
|
+
* @param {(tunnelId: string) => Promise<{token: string, expiresAt: number}>} opts.mintToken
|
|
1265
|
+
*/
|
|
1266
|
+
constructor({ devtunnel: devtunnel2, command, description, leftover, remember, mintToken }) {
|
|
1267
|
+
this.devtunnel = devtunnel2;
|
|
1268
|
+
this.command = command;
|
|
1269
|
+
this.description = description;
|
|
1270
|
+
this.leftover = leftover;
|
|
1271
|
+
this.remember = remember;
|
|
1272
|
+
this.mintToken = mintToken;
|
|
1273
|
+
}
|
|
1274
|
+
/** Runs tunnel changes one at a time: each may restart the host. */
|
|
1275
|
+
#serial(fn) {
|
|
1276
|
+
const result = this.#queue.then(fn);
|
|
1277
|
+
this.#queue = result.catch(() => {
|
|
1278
|
+
});
|
|
1279
|
+
return result;
|
|
1280
|
+
}
|
|
1281
|
+
list() {
|
|
1282
|
+
const now = Date.now();
|
|
1283
|
+
return [...this.#host?.urls ?? []].map(([port2, url]) => {
|
|
1284
|
+
const until = this.#shares.get(port2);
|
|
1285
|
+
return { port: port2, url, sharedUntil: until > now ? until : null };
|
|
1286
|
+
}).sort((a, b) => a.port - b.port);
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Lets anyone with the port's link open it, without signing in, for `hours`. Only that port of
|
|
1290
|
+
* the preview tunnel: the agent's own tunnel, and its shell, stay private. The relay ends it
|
|
1291
|
+
* on time by itself; `unshare` ends it sooner.
|
|
1292
|
+
*/
|
|
1293
|
+
share(port2, hours) {
|
|
1294
|
+
return this.#serial(async () => {
|
|
1295
|
+
if (!this.#host?.urls.has(port2)) throw new Error(`port ${port2} is not being previewed`);
|
|
1296
|
+
await this.devtunnel("access", "reset", this.#tunnelId, "-p", String(port2));
|
|
1297
|
+
const res = await this.devtunnel(
|
|
1298
|
+
"access",
|
|
1299
|
+
"create",
|
|
1300
|
+
this.#tunnelId,
|
|
1301
|
+
"-p",
|
|
1302
|
+
String(port2),
|
|
1303
|
+
"--anonymous",
|
|
1304
|
+
"--expiration",
|
|
1305
|
+
`${hours}h`,
|
|
1306
|
+
"--json"
|
|
1307
|
+
);
|
|
1308
|
+
if (!res.ok) throw new Error(`devtunnel access create failed: ${res.out.trim()}`);
|
|
1309
|
+
const entries = JSON.parse(res.out.slice(res.out.indexOf("{"))).accessControlEntries ?? [];
|
|
1310
|
+
const until = Date.parse(entries.find((e) => e.type === "Anonymous")?.expiration);
|
|
1311
|
+
this.#shares.set(port2, Number.isFinite(until) ? until : Date.now() + hours * 36e5);
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
unshare(port2) {
|
|
1315
|
+
return this.#serial(async () => {
|
|
1316
|
+
if (!this.#shares.delete(port2) || !this.#tunnelId) return;
|
|
1317
|
+
const res = await this.devtunnel("access", "reset", this.#tunnelId, "-p", String(port2));
|
|
1318
|
+
if (!res.ok) throw new Error(`devtunnel access reset failed: ${res.out.trim()}`);
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
/** @returns {Promise<{port: number, url: string}>} */
|
|
1322
|
+
forward(port2) {
|
|
1323
|
+
return this.#serial(async () => {
|
|
1324
|
+
const url = this.#host?.urls.get(port2);
|
|
1325
|
+
if (url) return { port: port2, url };
|
|
1326
|
+
const id = await this.#ensureTunnel();
|
|
1327
|
+
const res = await this.devtunnel("port", "create", id, "-p", String(port2), "--protocol", "http");
|
|
1328
|
+
if (!res.ok && !/already exists|conflict/i.test(res.out)) throw new Error(`devtunnel port create failed: ${res.out.trim()}`);
|
|
1329
|
+
this.#ports.add(port2);
|
|
1330
|
+
try {
|
|
1331
|
+
await this.#restartHost();
|
|
1332
|
+
} catch (e) {
|
|
1333
|
+
this.#ports.delete(port2);
|
|
1334
|
+
throw e;
|
|
1335
|
+
}
|
|
1336
|
+
const forwarded = this.#host?.urls.get(port2);
|
|
1337
|
+
if (!forwarded) throw new Error(`devtunnel host did not forward port ${port2}`);
|
|
1338
|
+
return { port: port2, url: forwarded };
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
unforward(port2) {
|
|
1342
|
+
return this.#serial(async () => {
|
|
1343
|
+
if (!this.#ports.delete(port2) || !this.#tunnelId) return;
|
|
1344
|
+
this.#shares.delete(port2);
|
|
1345
|
+
await this.devtunnel("port", "delete", this.#tunnelId, "-p", String(port2));
|
|
1346
|
+
if (this.#ports.size) await this.#restartHost();
|
|
1347
|
+
else this.#stopHost();
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
/** A connect token for the preview tunnel, or null before anything is forwarded. */
|
|
1351
|
+
async token() {
|
|
1352
|
+
if (!this.#tunnelId) return null;
|
|
1353
|
+
if (!this.#token || this.#token.expiresAt - Date.now() < TOKEN_REUSE_MS) {
|
|
1354
|
+
this.#token = await this.mintToken(this.#tunnelId);
|
|
1355
|
+
}
|
|
1356
|
+
return this.#token;
|
|
1357
|
+
}
|
|
1358
|
+
/** Stops hosting; the tunnel is deleted on the next run's first preview. */
|
|
1359
|
+
stop() {
|
|
1360
|
+
this.#stopHost();
|
|
1361
|
+
}
|
|
1362
|
+
async #ensureTunnel() {
|
|
1363
|
+
if (this.#tunnelId) return this.#tunnelId;
|
|
1364
|
+
if (this.leftover) await this.devtunnel("delete", this.leftover, "-f");
|
|
1365
|
+
const created = await this.devtunnel(
|
|
1366
|
+
"create",
|
|
1367
|
+
"--expiration",
|
|
1368
|
+
"1d",
|
|
1369
|
+
"-d",
|
|
1370
|
+
this.description,
|
|
1371
|
+
"--labels",
|
|
1372
|
+
"coderemote-preview",
|
|
1373
|
+
"--json"
|
|
1374
|
+
);
|
|
1375
|
+
if (!created.ok) throw new Error(`devtunnel create failed: ${created.out.trim()}`);
|
|
1376
|
+
const id = JSON.parse(created.out.slice(created.out.indexOf("{"))).tunnel?.tunnelId;
|
|
1377
|
+
if (!id) throw new Error(`could not parse tunnel id from:
|
|
1378
|
+
${created.out}`);
|
|
1379
|
+
this.#tunnelId = id;
|
|
1380
|
+
this.leftover = void 0;
|
|
1381
|
+
this.remember(id);
|
|
1382
|
+
return id;
|
|
1383
|
+
}
|
|
1384
|
+
/** Starts a host with the current ports; once it's ready, the old one (already displaced) is killed. */
|
|
1385
|
+
async #restartHost() {
|
|
1386
|
+
const old = this.#host;
|
|
1387
|
+
const next = await this.#startHost();
|
|
1388
|
+
this.#host = next;
|
|
1389
|
+
old?.child.kill();
|
|
1390
|
+
}
|
|
1391
|
+
#startHost() {
|
|
1392
|
+
return new Promise((resolve, reject) => {
|
|
1393
|
+
const child = spawn(this.command(), ["host", this.#tunnelId], { stdio: ["ignore", "pipe", "pipe"] });
|
|
1394
|
+
const urls = /* @__PURE__ */ new Map();
|
|
1395
|
+
let log2 = "";
|
|
1396
|
+
const timer = setTimeout(() => {
|
|
1397
|
+
child.kill();
|
|
1398
|
+
reject(new Error(`devtunnel host did not get ready:
|
|
1399
|
+
${log2}`));
|
|
1400
|
+
}, HOST_READY_TIMEOUT_MS);
|
|
1401
|
+
const onData = (d) => {
|
|
1402
|
+
log2 += d;
|
|
1403
|
+
for (const m of log2.matchAll(/https:\/\/([a-z0-9]+)-(\d+)\.([a-z0-9.]*devtunnels\.ms)/g)) {
|
|
1404
|
+
urls.set(parseInt(m[2], 10), m[0]);
|
|
1405
|
+
}
|
|
1406
|
+
if (/Ready to accept connections/.test(log2)) {
|
|
1407
|
+
clearTimeout(timer);
|
|
1408
|
+
resolve({ child, urls });
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
child.stdout.on("data", onData);
|
|
1412
|
+
child.stderr.on("data", onData);
|
|
1413
|
+
child.on("exit", (code) => {
|
|
1414
|
+
clearTimeout(timer);
|
|
1415
|
+
reject(new Error(`devtunnel host exited (${code}):
|
|
1416
|
+
${log2}`));
|
|
1417
|
+
if (this.#host?.child === child) this.#host = null;
|
|
1418
|
+
});
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
#stopHost() {
|
|
1422
|
+
this.#host?.child.kill();
|
|
1423
|
+
this.#host = null;
|
|
1424
|
+
}
|
|
1425
|
+
};
|
|
1426
|
+
|
|
825
1427
|
// src/registry.js
|
|
826
|
-
import
|
|
1428
|
+
import fs6 from "node:fs";
|
|
827
1429
|
import path5 from "node:path";
|
|
828
1430
|
var AgentRegistry = class {
|
|
829
1431
|
constructor(dir) {
|
|
830
1432
|
this.dir = dir;
|
|
831
1433
|
}
|
|
832
1434
|
register({ port: port2, root: root2, url }) {
|
|
833
|
-
|
|
1435
|
+
fs6.mkdirSync(this.dir, { recursive: true, mode: 448 });
|
|
834
1436
|
const file = this.#file(port2);
|
|
835
|
-
|
|
1437
|
+
fs6.writeFileSync(file, JSON.stringify({ pid: process.pid, port: port2, root: root2, url, startedAt: Date.now() }), { mode: 384 });
|
|
836
1438
|
const cleanup = () => {
|
|
837
1439
|
try {
|
|
838
|
-
if (JSON.parse(
|
|
1440
|
+
if (JSON.parse(fs6.readFileSync(file, "utf8")).pid === process.pid) fs6.rmSync(file);
|
|
839
1441
|
} catch {
|
|
840
1442
|
}
|
|
841
1443
|
};
|
|
@@ -846,7 +1448,7 @@ var AgentRegistry = class {
|
|
|
846
1448
|
list() {
|
|
847
1449
|
let names = [];
|
|
848
1450
|
try {
|
|
849
|
-
names =
|
|
1451
|
+
names = fs6.readdirSync(this.dir).filter((n) => n.endsWith(".json"));
|
|
850
1452
|
} catch {
|
|
851
1453
|
return [];
|
|
852
1454
|
}
|
|
@@ -854,11 +1456,11 @@ var AgentRegistry = class {
|
|
|
854
1456
|
for (const name of names) {
|
|
855
1457
|
const file = path5.join(this.dir, name);
|
|
856
1458
|
try {
|
|
857
|
-
const agent = JSON.parse(
|
|
1459
|
+
const agent = JSON.parse(fs6.readFileSync(file, "utf8"));
|
|
858
1460
|
process.kill(agent.pid, 0);
|
|
859
1461
|
agents.push(agent);
|
|
860
1462
|
} catch {
|
|
861
|
-
|
|
1463
|
+
fs6.rmSync(file, { force: true });
|
|
862
1464
|
}
|
|
863
1465
|
}
|
|
864
1466
|
return agents.sort((a, b) => a.port - b.port);
|
|
@@ -868,6 +1470,65 @@ var AgentRegistry = class {
|
|
|
868
1470
|
}
|
|
869
1471
|
};
|
|
870
1472
|
|
|
1473
|
+
// src/tunnel-status.js
|
|
1474
|
+
var MACHINE_LABEL = "coderemote";
|
|
1475
|
+
var CACHE_MS = 15e3;
|
|
1476
|
+
function parse(out) {
|
|
1477
|
+
return JSON.parse(out.slice(out.indexOf("{")));
|
|
1478
|
+
}
|
|
1479
|
+
function machineName(description) {
|
|
1480
|
+
return (description || "").replace(/^(coderemote|vsremote)\s+/, "").replace(/\.local$/, "") || null;
|
|
1481
|
+
}
|
|
1482
|
+
function machine(tunnel, current) {
|
|
1483
|
+
return {
|
|
1484
|
+
id: tunnel.tunnelId,
|
|
1485
|
+
name: machineName(tunnel.description),
|
|
1486
|
+
// The agent's address, the same URL its pairing link carries.
|
|
1487
|
+
url: tunnel.ports?.[0]?.portUri?.replace(/\/$/, "") ?? null,
|
|
1488
|
+
// This agent is answering, so its own tunnel is hosted: the service's count lags a fresh start.
|
|
1489
|
+
online: current || tunnel.hostConnections > 0,
|
|
1490
|
+
current
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
function tunnelStatus({ devtunnel: devtunnel2, tunnelId, account, anonymous: anonymous2, preview }) {
|
|
1494
|
+
let cached = null;
|
|
1495
|
+
return async () => {
|
|
1496
|
+
const id = tunnelId();
|
|
1497
|
+
if (!id) return null;
|
|
1498
|
+
if (cached && Date.now() - cached.at < CACHE_MS) return { ...cached.value, previewPorts: preview?.list().length ?? 0 };
|
|
1499
|
+
const [own, listed] = await Promise.all([
|
|
1500
|
+
devtunnel2("show", id, "--json"),
|
|
1501
|
+
devtunnel2("list", "--labels", MACHINE_LABEL, "--json")
|
|
1502
|
+
]);
|
|
1503
|
+
if (!own.ok) throw new Error(`devtunnel show failed: ${own.out.trim()}`);
|
|
1504
|
+
const tunnel = parse(own.out).tunnel;
|
|
1505
|
+
const others = listed.ok ? (parse(listed.out).tunnels ?? []).filter((t) => t.tunnelId !== id) : [];
|
|
1506
|
+
const shown = await Promise.all(
|
|
1507
|
+
others.map(async (t) => {
|
|
1508
|
+
const res = await devtunnel2("show", t.tunnelId, "--json");
|
|
1509
|
+
return res.ok ? parse(res.out).tunnel : null;
|
|
1510
|
+
})
|
|
1511
|
+
);
|
|
1512
|
+
const machines = [machine(tunnel, true), ...shown.filter(Boolean).map((t) => machine(t, false))];
|
|
1513
|
+
const value = {
|
|
1514
|
+
account: account() ?? null,
|
|
1515
|
+
tunnel: {
|
|
1516
|
+
id,
|
|
1517
|
+
url: machine(tunnel, true).url,
|
|
1518
|
+
access: anonymous2 ? "anonymous" : "private",
|
|
1519
|
+
// "30 days": pushed back every time `coderemote up` starts.
|
|
1520
|
+
expiration: tunnel.tunnelExpiration ?? null,
|
|
1521
|
+
connections: tunnel.clientConnections ?? 0,
|
|
1522
|
+
uploaded: tunnel.uploadTotal ?? null,
|
|
1523
|
+
downloaded: tunnel.downloadTotal ?? null
|
|
1524
|
+
},
|
|
1525
|
+
machines
|
|
1526
|
+
};
|
|
1527
|
+
cached = { at: Date.now(), value };
|
|
1528
|
+
return { ...value, previewPorts: preview?.list().length ?? 0 };
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
|
|
871
1532
|
// src/spinner.js
|
|
872
1533
|
var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
873
1534
|
var dim = (s) => `\x1B[2m${s}\x1B[22m`;
|
|
@@ -902,8 +1563,8 @@ var Spinner = class {
|
|
|
902
1563
|
this.timer = setInterval(() => this.render(FRAMES[++frame % FRAMES.length]), 80);
|
|
903
1564
|
}
|
|
904
1565
|
/** Extra status shown after the step's text while it runs, e.g. a download percentage. */
|
|
905
|
-
update(
|
|
906
|
-
this.detail =
|
|
1566
|
+
update(detail2) {
|
|
1567
|
+
this.detail = detail2;
|
|
907
1568
|
}
|
|
908
1569
|
succeed() {
|
|
909
1570
|
this.finish(green("\u2714"));
|
|
@@ -921,8 +1582,8 @@ var Spinner = class {
|
|
|
921
1582
|
this.text = null;
|
|
922
1583
|
}
|
|
923
1584
|
render(frame) {
|
|
924
|
-
const
|
|
925
|
-
this.stream.write(`\r\x1B[2K ${cyan(frame)} ${this.text}${
|
|
1585
|
+
const detail2 = this.detail ? ` ${this.detail}` : "";
|
|
1586
|
+
this.stream.write(`\r\x1B[2K ${cyan(frame)} ${this.text}${detail2} ${dim(this.elapsed())}`);
|
|
926
1587
|
}
|
|
927
1588
|
finish(mark) {
|
|
928
1589
|
if (!this.text) return;
|
|
@@ -937,18 +1598,68 @@ var Spinner = class {
|
|
|
937
1598
|
}
|
|
938
1599
|
};
|
|
939
1600
|
|
|
1601
|
+
// src/vscode-tunnel.js
|
|
1602
|
+
import { execFile as execFile4, spawn as spawn2 } from "node:child_process";
|
|
1603
|
+
import { promisify as promisify2 } from "node:util";
|
|
1604
|
+
var run3 = promisify2(execFile4);
|
|
1605
|
+
function findCode() {
|
|
1606
|
+
return process.env.CODEREMOTE_CODE || onPath("code");
|
|
1607
|
+
}
|
|
1608
|
+
async function codeTunnelSignedIn(code) {
|
|
1609
|
+
try {
|
|
1610
|
+
const { stdout } = await run3(code, ["tunnel", "user", "show"]);
|
|
1611
|
+
return /^logged in/im.test(stdout);
|
|
1612
|
+
} catch {
|
|
1613
|
+
return false;
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
function codeTunnelLogin(code) {
|
|
1617
|
+
return new Promise((resolve) => {
|
|
1618
|
+
spawn2(code, ["tunnel", "user", "login", "--provider", "github"], { stdio: "inherit" }).on("exit", resolve);
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
function hostCodeTunnel(code, root2, timeoutMs = 6e4) {
|
|
1622
|
+
return new Promise((resolve, reject) => {
|
|
1623
|
+
const child = spawn2(code, ["tunnel"], { cwd: root2, stdio: ["ignore", "pipe", "pipe"] });
|
|
1624
|
+
let log2 = "";
|
|
1625
|
+
const timer = setTimeout(() => {
|
|
1626
|
+
child.kill();
|
|
1627
|
+
reject(new Error(`code tunnel printed no vscode.dev link within ${timeoutMs / 1e3}s:
|
|
1628
|
+
${log2}`));
|
|
1629
|
+
}, timeoutMs);
|
|
1630
|
+
const onData = (d) => {
|
|
1631
|
+
log2 += String(d).replace(/\x1b\[[0-9;]*m/g, "");
|
|
1632
|
+
const m = log2.match(/https:\/\/vscode\.dev\/tunnel\/([^/\s]+)/);
|
|
1633
|
+
if (!m) return;
|
|
1634
|
+
clearTimeout(timer);
|
|
1635
|
+
resolve({ child, url: `https://vscode.dev/tunnel/${m[1]}${encodeURI(root2)}` });
|
|
1636
|
+
};
|
|
1637
|
+
child.stdout.on("data", onData);
|
|
1638
|
+
child.stderr.on("data", onData);
|
|
1639
|
+
child.on("error", (e) => {
|
|
1640
|
+
clearTimeout(timer);
|
|
1641
|
+
reject(e);
|
|
1642
|
+
});
|
|
1643
|
+
child.on("exit", (exitCode) => {
|
|
1644
|
+
clearTimeout(timer);
|
|
1645
|
+
reject(new Error(`code tunnel exited (${exitCode}):
|
|
1646
|
+
${log2}`));
|
|
1647
|
+
});
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
|
|
940
1651
|
// bin/coderemote.js
|
|
941
|
-
var
|
|
942
|
-
var CONFIG_DIR = path6.join(
|
|
943
|
-
var LEGACY_CONFIG_DIR = path6.join(
|
|
944
|
-
if (!
|
|
1652
|
+
var run4 = promisify3(execFile5);
|
|
1653
|
+
var CONFIG_DIR = path6.join(os4.homedir(), ".coderemote");
|
|
1654
|
+
var LEGACY_CONFIG_DIR = path6.join(os4.homedir(), ".vsremote");
|
|
1655
|
+
if (!fs7.existsSync(CONFIG_DIR) && fs7.existsSync(LEGACY_CONFIG_DIR)) fs7.renameSync(LEGACY_CONFIG_DIR, CONFIG_DIR);
|
|
945
1656
|
var CONFIG_FILE = path6.join(CONFIG_DIR, "config.json");
|
|
946
1657
|
var DEFAULT_PORT = 31580;
|
|
947
1658
|
var spinner = new Spinner();
|
|
948
1659
|
function loadConfig() {
|
|
949
1660
|
let cfg2 = {};
|
|
950
1661
|
try {
|
|
951
|
-
cfg2 = JSON.parse(
|
|
1662
|
+
cfg2 = JSON.parse(fs7.readFileSync(CONFIG_FILE, "utf8"));
|
|
952
1663
|
} catch {
|
|
953
1664
|
}
|
|
954
1665
|
if (!cfg2.token) {
|
|
@@ -958,11 +1669,11 @@ function loadConfig() {
|
|
|
958
1669
|
return cfg2;
|
|
959
1670
|
}
|
|
960
1671
|
function saveConfig(cfg2) {
|
|
961
|
-
|
|
962
|
-
|
|
1672
|
+
fs7.mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
|
|
1673
|
+
fs7.writeFileSync(CONFIG_FILE, JSON.stringify(cfg2, null, 2), { mode: 384 });
|
|
963
1674
|
}
|
|
964
|
-
function listen(token, port2, root2, tunnelToken) {
|
|
965
|
-
const server = createAgentServer({ token, root: root2, tunnelToken });
|
|
1675
|
+
function listen(token, port2, root2, tunnelToken, preview, status2) {
|
|
1676
|
+
const server = createAgentServer({ token, root: root2, tunnelToken, preview, tunnelStatus: status2 });
|
|
966
1677
|
return new Promise((resolve, reject) => {
|
|
967
1678
|
server.once("error", reject);
|
|
968
1679
|
server.listen(port2, "127.0.0.1", () => resolve(server));
|
|
@@ -973,19 +1684,19 @@ async function resolveDevtunnel() {
|
|
|
973
1684
|
let installing = false;
|
|
974
1685
|
DEVTUNNEL = await ensureDevtunnel({
|
|
975
1686
|
dir: path6.join(CONFIG_DIR, "bin"),
|
|
976
|
-
progress: (
|
|
1687
|
+
progress: (detail2) => {
|
|
977
1688
|
if (!installing) {
|
|
978
1689
|
installing = true;
|
|
979
1690
|
spinner.start("Installing devtunnel (Microsoft Dev Tunnels CLI, one time)");
|
|
980
1691
|
}
|
|
981
|
-
spinner.update(
|
|
1692
|
+
spinner.update(detail2);
|
|
982
1693
|
}
|
|
983
1694
|
});
|
|
984
1695
|
if (installing) spinner.succeed();
|
|
985
1696
|
}
|
|
986
1697
|
async function devtunnel(...args) {
|
|
987
1698
|
try {
|
|
988
|
-
const { stdout } = await
|
|
1699
|
+
const { stdout } = await run4(DEVTUNNEL, args);
|
|
989
1700
|
return { ok: true, out: stdout };
|
|
990
1701
|
} catch (e) {
|
|
991
1702
|
return { ok: false, out: `${e.stdout || ""}${e.stderr || ""}` || e.message };
|
|
@@ -999,7 +1710,7 @@ function loginArgs(opts = rest) {
|
|
|
999
1710
|
}
|
|
1000
1711
|
function interactiveLogin(args) {
|
|
1001
1712
|
return new Promise((resolve) => {
|
|
1002
|
-
|
|
1713
|
+
spawn3(DEVTUNNEL, args, { stdio: "inherit" }).on("exit", resolve);
|
|
1003
1714
|
});
|
|
1004
1715
|
}
|
|
1005
1716
|
var LOGIN_NEEDED = /not logged in|login token expired|login required|please log ?in/i;
|
|
@@ -1031,7 +1742,7 @@ function applyAccess(tunnelId, anonymous2) {
|
|
|
1031
1742
|
async function ensureTunnel(cfg2, port2, anonymous2) {
|
|
1032
1743
|
if (cfg2.tunnelId) {
|
|
1033
1744
|
const [updated, access] = await Promise.all([
|
|
1034
|
-
devtunnel("update", cfg2.tunnelId, "--expiration", "30d"),
|
|
1745
|
+
devtunnel("update", cfg2.tunnelId, "--expiration", "30d", "--add-labels", MACHINE_LABEL),
|
|
1035
1746
|
applyAccess(cfg2.tunnelId, anonymous2)
|
|
1036
1747
|
]);
|
|
1037
1748
|
if (updated.ok) {
|
|
@@ -1042,7 +1753,7 @@ ${access.out}`);
|
|
|
1042
1753
|
if (!/not found/i.test(updated.out)) throw new Error(`devtunnel update failed:
|
|
1043
1754
|
${updated.out}`);
|
|
1044
1755
|
}
|
|
1045
|
-
const createArgs = ["create", "--expiration", "30d", "-d", `coderemote ${
|
|
1756
|
+
const createArgs = ["create", "--expiration", "30d", "-d", `coderemote ${os4.hostname()}`, "--labels", MACHINE_LABEL, "--json"];
|
|
1046
1757
|
if (anonymous2) createArgs.push("--allow-anonymous");
|
|
1047
1758
|
const created = await devtunnel(...createArgs);
|
|
1048
1759
|
if (!created.ok) throw new Error(`devtunnel create failed:
|
|
@@ -1068,7 +1779,7 @@ ${res.out}`);
|
|
|
1068
1779
|
}
|
|
1069
1780
|
function hostTunnel(tunnelId, port2) {
|
|
1070
1781
|
return new Promise((resolve, reject) => {
|
|
1071
|
-
const child =
|
|
1782
|
+
const child = spawn3(DEVTUNNEL, ["host", tunnelId], { stdio: ["ignore", "pipe", "pipe"] });
|
|
1072
1783
|
let log2 = "";
|
|
1073
1784
|
const onData = (d) => {
|
|
1074
1785
|
log2 += d;
|
|
@@ -1081,13 +1792,16 @@ function hostTunnel(tunnelId, port2) {
|
|
|
1081
1792
|
${log2}`)));
|
|
1082
1793
|
});
|
|
1083
1794
|
}
|
|
1084
|
-
function printPairing(url, token, access) {
|
|
1795
|
+
function printPairing(url, token, access, vscodeUrl) {
|
|
1085
1796
|
const pair = pairingUrl({ url, token });
|
|
1086
1797
|
if (access) console.log(` Access: ${access}`);
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
console.log(`
|
|
1798
|
+
const label = (name) => `${name}:`.padEnd(vscodeUrl ? 9 : 4);
|
|
1799
|
+
console.log("");
|
|
1800
|
+
if (vscodeUrl) console.log(` ${label("VS Code")} ${vscodeUrl}`);
|
|
1801
|
+
console.log(` ${label("Web")} ${webUrl({ url, token })}`);
|
|
1802
|
+
console.log(` ${label("App")} ${pair}
|
|
1090
1803
|
`);
|
|
1804
|
+
if (vscodeUrl) console.log(" VS Code opens in the browser after signing in with the account of `code tunnel`.");
|
|
1091
1805
|
console.log(" Scan with the iPhone Camera, or open either link on the phone:\n");
|
|
1092
1806
|
qrcode.generate(pair, { small: true });
|
|
1093
1807
|
console.log(
|
|
@@ -1095,6 +1809,37 @@ function printPairing(url, token, access) {
|
|
|
1095
1809
|
);
|
|
1096
1810
|
console.log(" Run `coderemote rotate-token` to revoke every paired device.\n");
|
|
1097
1811
|
}
|
|
1812
|
+
async function startVSCodeTunnel() {
|
|
1813
|
+
const code = findCode();
|
|
1814
|
+
if (!code) {
|
|
1815
|
+
console.error("--web needs VS Code's `code` command on PATH (in VS Code: Shell Command: Install 'code' command in PATH).");
|
|
1816
|
+
return null;
|
|
1817
|
+
}
|
|
1818
|
+
if (!await codeTunnelSignedIn(code)) {
|
|
1819
|
+
if (!process.stdin.isTTY) {
|
|
1820
|
+
console.error("--web: VS Code tunnel is not signed in. Run `code tunnel user login`, then try again.");
|
|
1821
|
+
return null;
|
|
1822
|
+
}
|
|
1823
|
+
console.error("Signing in to VS Code tunnels with GitHub...\n");
|
|
1824
|
+
if (await codeTunnelLogin(code) !== 0) {
|
|
1825
|
+
console.error("\n--web: sign-in failed. Run `code tunnel user login`, then try again.");
|
|
1826
|
+
return null;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
spinner.start("Starting VS Code tunnel");
|
|
1830
|
+
try {
|
|
1831
|
+
const { child, url } = await hostCodeTunnel(code, root);
|
|
1832
|
+
spinner.succeed();
|
|
1833
|
+
process.on("exit", () => child.kill());
|
|
1834
|
+
return url;
|
|
1835
|
+
} catch (e) {
|
|
1836
|
+
spinner.fail();
|
|
1837
|
+
console.error(`${e.message.trim()}
|
|
1838
|
+
`);
|
|
1839
|
+
console.error("--web: run `code tunnel` once in a terminal (license, machine name), then try again.");
|
|
1840
|
+
return null;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1098
1843
|
var USAGE = `Usage: coderemote <command> [options]
|
|
1099
1844
|
|
|
1100
1845
|
up share this folder through a dev tunnel and print the pairing QR;
|
|
@@ -1111,7 +1856,9 @@ Options:
|
|
|
1111
1856
|
--root DIR folder to share (default: current folder)
|
|
1112
1857
|
--port N local port (default: ${DEFAULT_PORT})
|
|
1113
1858
|
--allow-anonymous up: skip the sign-in, the pairing token alone grants access
|
|
1114
|
-
(alias --no-auth)
|
|
1859
|
+
(alias --no-auth)
|
|
1860
|
+
--web up: also run \`code tunnel\` and print a vscode.dev link that opens
|
|
1861
|
+
this folder in VS Code in the browser (needs VS Code's \`code\`)`;
|
|
1115
1862
|
var HELP_FLAGS = ["help", "--help", "-help", "-h"];
|
|
1116
1863
|
var argv = process.argv.slice(2);
|
|
1117
1864
|
var [cmd, ...rest] = argv.length === 0 || argv.some((a) => HELP_FLAGS.includes(a)) ? ["help"] : argv;
|
|
@@ -1134,9 +1881,22 @@ async function start({ tunnel }) {
|
|
|
1134
1881
|
let server;
|
|
1135
1882
|
let tunnelId;
|
|
1136
1883
|
const tunnelToken = async () => tunnel && !anonymous && tunnelId ? connectToken(tunnelId) : null;
|
|
1884
|
+
const preview = tunnel ? new PreviewTunnel({
|
|
1885
|
+
devtunnel,
|
|
1886
|
+
command: () => DEVTUNNEL,
|
|
1887
|
+
description: `coderemote preview ${os4.hostname()}`,
|
|
1888
|
+
leftover: cfg.previewTunnels?.[port],
|
|
1889
|
+
remember: (id) => {
|
|
1890
|
+
cfg.previewTunnels = { ...cfg.previewTunnels, [port]: id };
|
|
1891
|
+
saveConfig(cfg);
|
|
1892
|
+
},
|
|
1893
|
+
mintToken: connectToken
|
|
1894
|
+
}) : null;
|
|
1895
|
+
let account;
|
|
1896
|
+
const status2 = tunnel ? tunnelStatus({ devtunnel, tunnelId: () => tunnelId, account: () => account, anonymous, preview }) : null;
|
|
1137
1897
|
spinner.start("Starting agent");
|
|
1138
1898
|
try {
|
|
1139
|
-
server = await listen(cfg.token, port, root, tunnelToken);
|
|
1899
|
+
server = await listen(cfg.token, port, root, tunnelToken, preview, status2);
|
|
1140
1900
|
} catch (e) {
|
|
1141
1901
|
spinner.fail();
|
|
1142
1902
|
if (e.code !== "EADDRINUSE") throw e;
|
|
@@ -1153,6 +1913,7 @@ async function start({ tunnel }) {
|
|
|
1153
1913
|
await resolveDevtunnel();
|
|
1154
1914
|
spinner.start("Checking dev tunnels sign-in");
|
|
1155
1915
|
const user = await tunnelUser();
|
|
1916
|
+
account = user;
|
|
1156
1917
|
spinner.succeed();
|
|
1157
1918
|
spinner.start("Connecting to the relay");
|
|
1158
1919
|
const known = cfg.tunnelId;
|
|
@@ -1172,6 +1933,7 @@ async function start({ tunnel }) {
|
|
|
1172
1933
|
registry.register({ port, root, url });
|
|
1173
1934
|
const shutdown = () => {
|
|
1174
1935
|
child?.kill();
|
|
1936
|
+
preview?.stop();
|
|
1175
1937
|
server.killAllSessions();
|
|
1176
1938
|
server.close();
|
|
1177
1939
|
process.exit(0);
|
|
@@ -1179,7 +1941,10 @@ async function start({ tunnel }) {
|
|
|
1179
1941
|
process.on("SIGINT", shutdown);
|
|
1180
1942
|
process.on("SIGTERM", shutdown);
|
|
1181
1943
|
process.on("SIGHUP", shutdown);
|
|
1182
|
-
process.on("exit", () =>
|
|
1944
|
+
process.on("exit", () => {
|
|
1945
|
+
child?.kill();
|
|
1946
|
+
preview?.stop();
|
|
1947
|
+
});
|
|
1183
1948
|
return { url, access };
|
|
1184
1949
|
}
|
|
1185
1950
|
async function agentApi(agent, method, route, query = {}) {
|
|
@@ -1222,9 +1987,10 @@ Token: ${cfg.token}`);
|
|
|
1222
1987
|
throw e;
|
|
1223
1988
|
}
|
|
1224
1989
|
const { url, access } = started;
|
|
1990
|
+
const vscodeUrl = rest.includes("--web") ? await startVSCodeTunnel() : null;
|
|
1225
1991
|
console.log(`
|
|
1226
1992
|
Sharing: ${root}`);
|
|
1227
|
-
printPairing(url, cfg.token, access);
|
|
1993
|
+
printPairing(url, cfg.token, access, vscodeUrl);
|
|
1228
1994
|
break;
|
|
1229
1995
|
}
|
|
1230
1996
|
case "status":
|