coderemote 0.1.2 → 0.2.0

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