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.
@@ -1,19 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // bin/coderemote.js
4
- import { execFile as execFile3, spawn } from "node:child_process";
4
+ import { execFile as execFile5, spawn as spawn3 } from "node:child_process";
5
5
  import { randomBytes } from "node:crypto";
6
- import fs6 from "node:fs";
7
- import os3 from "node:os";
8
- import path6 from "node:path";
9
- import { promisify as promisify2 } from "node:util";
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 fs3 from "node:fs/promises";
15
- import http from "node:http";
16
- import os2 from "node:os";
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
- tunnelToken: "POST /api/tunnel/token"
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
- const token = new URLSearchParams(location.hash.slice(1)).get('token');
356
- if (token) {
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 os from "node:os";
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 || (os.platform() === "win32" ? "powershell.exe" : "/bin/sh");
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 || os.homedir(),
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 fs3.lstat(fullPath);
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 fs3.stat(fullPath));
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 fs3.access(path3.join(repo, ".git"));
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: os2.hostname(),
539
- platform: os2.platform(),
887
+ hostname: os3.hostname(),
888
+ platform: os3.platform(),
540
889
  root: sandbox.root,
541
- user: os2.userInfo().username,
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 fs3.readdir(dir);
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 fs3.stat(p);
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 fs3.rename(tmp, p);
915
+ await fs4.rename(tmp, p);
567
916
  } catch (e) {
568
- await fs3.rm(tmp, { force: true });
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 fs3.mkdir(await sandbox.resolve(url.searchParams.get("path"), { allowMissing: true }), { recursive: true });
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 fs3.rename(await sandbox.resolveChild(from), await sandbox.resolveChild(to, { allowMissing: true }));
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 fs3.rm(await sandbox.resolveChild(url.searchParams.get("path")), { recursive: true });
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 = http.createServer(async (req, res) => {
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", () => sessions.killAll());
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 execFile2 } from "node:child_process";
1137
+ import { execFile as execFile3 } from "node:child_process";
718
1138
  import { createHash } from "node:crypto";
719
- import fs4 from "node:fs";
1139
+ import fs5 from "node:fs";
720
1140
  import path4 from "node:path";
721
1141
  import { promisify } from "node:util";
722
- var run = promisify(execFile2);
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
- fs4.accessSync(file, fs4.constants.X_OK);
735
- return fs4.statSync(file).isFile();
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(fs4.readFileSync(metaFile, "utf8"));
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
- fs4.writeFileSync(metaFile, JSON.stringify({ ...meta, checkedAt: Date.now() }));
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
- fs4.mkdirSync(path4.dirname(bin), { recursive: true, mode: 448 });
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 = fs4.createWriteStream(tmp, { mode: 493 });
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
- fs4.chmodSync(tmp, 493);
805
- fs4.renameSync(tmp, bin);
1224
+ fs5.chmodSync(tmp, 493);
1225
+ fs5.renameSync(tmp, bin);
806
1226
  } catch (e) {
807
1227
  out.destroy();
808
- fs4.rmSync(tmp, { force: true });
1228
+ fs5.rmSync(tmp, { force: true });
809
1229
  throw e;
810
1230
  }
811
- fs4.writeFileSync(metaFile, JSON.stringify({ lastModified: res.headers.get("last-modified"), checkedAt: Date.now() }));
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 run("codesign", ["--verify", "--strict", file]);
818
- const { stderr } = await run("codesign", ["-dv", file]);
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 fs5 from "node:fs";
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
- fs5.mkdirSync(this.dir, { recursive: true, mode: 448 });
1436
+ fs6.mkdirSync(this.dir, { recursive: true, mode: 448 });
834
1437
  const file = this.#file(port2);
835
- fs5.writeFileSync(file, JSON.stringify({ pid: process.pid, port: port2, root: root2, url, startedAt: Date.now() }), { mode: 384 });
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(fs5.readFileSync(file, "utf8")).pid === process.pid) fs5.rmSync(file);
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 = fs5.readdirSync(this.dir).filter((n) => n.endsWith(".json"));
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(fs5.readFileSync(file, "utf8"));
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
- fs5.rmSync(file, { force: true });
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(detail) {
906
- this.detail = 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 detail = this.detail ? ` ${this.detail}` : "";
925
- this.stream.write(`\r\x1B[2K ${cyan(frame)} ${this.text}${detail} ${dim(this.elapsed())}`);
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 run2 = promisify2(execFile3);
942
- var CONFIG_DIR = path6.join(os3.homedir(), ".coderemote");
943
- var LEGACY_CONFIG_DIR = path6.join(os3.homedir(), ".vsremote");
944
- if (!fs6.existsSync(CONFIG_DIR) && fs6.existsSync(LEGACY_CONFIG_DIR)) fs6.renameSync(LEGACY_CONFIG_DIR, CONFIG_DIR);
945
- var CONFIG_FILE = path6.join(CONFIG_DIR, "config.json");
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(fs6.readFileSync(CONFIG_FILE, "utf8"));
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
- fs6.mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
962
- fs6.writeFileSync(CONFIG_FILE, JSON.stringify(cfg2, null, 2), { mode: 384 });
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: path6.join(CONFIG_DIR, "bin"),
976
- progress: (detail) => {
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(detail);
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 run2(DEVTUNNEL, args);
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
- spawn(DEVTUNNEL, args, { stdio: "inherit" }).on("exit", resolve);
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 ${os3.hostname()}`, "--json"];
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 = spawn(DEVTUNNEL, ["host", tunnelId], { stdio: ["ignore", "pipe", "pipe"] });
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
- console.log(`
1088
- Web: ${webUrl({ url, token })}`);
1089
- console.log(` App: ${pair}
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 = path6.resolve(flag("--root") ?? process.cwd());
2000
+ var root = path7.resolve(flag("--root") ?? process.cwd());
1125
2001
  var cfg = loadConfig();
1126
- var registry = new AgentRegistry(path6.join(CONFIG_DIR, "agents"));
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", () => child?.kill());
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;