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