coderemote 0.0.0-stage → 0.1.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.
@@ -0,0 +1,1162 @@
1
+ #!/usr/bin/env node
2
+
3
+ // bin/coderemote.js
4
+ import { execFile as execFile2, spawn } from "node:child_process";
5
+ import { randomBytes } from "node:crypto";
6
+ import fs5 from "node:fs";
7
+ import os3 from "node:os";
8
+ import path5 from "node:path";
9
+ import { promisify } from "node:util";
10
+ import qrcode from "qrcode-terminal";
11
+
12
+ // ../../packages/agent-core/src/server.js
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";
17
+ import path3 from "node:path";
18
+ import { pipeline } from "node:stream/promises";
19
+ import { timingSafeEqual as timingSafeEqual2 } from "node:crypto";
20
+
21
+ // ../../packages/protocol/index.js
22
+ var PROTOCOL_VERSION = 4;
23
+ var PAIRING_SCHEME = "vsremote";
24
+ var Routes = Object.freeze({
25
+ info: "GET /api/info",
26
+ fsList: "GET /api/fs/list",
27
+ fsStat: "GET /api/fs/stat",
28
+ fsRead: "GET /api/fs/read",
29
+ fsWrite: "PUT /api/fs/write",
30
+ fsMkdir: "POST /api/fs/mkdir",
31
+ fsRename: "POST /api/fs/rename",
32
+ fsDelete: "DELETE /api/fs",
33
+ termList: "GET /api/term",
34
+ termKill: "DELETE /api/term",
35
+ gitRepos: "GET /api/git/repos",
36
+ gitStatus: "GET /api/git/status",
37
+ gitDiff: "GET /api/git/diff",
38
+ gitStage: "POST /api/git/stage",
39
+ gitUnstage: "POST /api/git/unstage",
40
+ gitDiscard: "POST /api/git/discard",
41
+ gitCommit: "POST /api/git/commit",
42
+ gitLog: "GET /api/git/log",
43
+ gitShow: "GET /api/git/show",
44
+ gitPull: "POST /api/git/pull",
45
+ gitPush: "POST /api/git/push",
46
+ tunnelToken: "POST /api/tunnel/token"
47
+ });
48
+ var TUNNEL_HANDOFF_PATH = "/vsremote/handoff";
49
+ var TUNNEL_AUTH_CALLBACK_HOST = "tunnel-auth";
50
+ var TERMINAL_WS_PATH = "/api/term/ws";
51
+ var TerminalServerMessage = Object.freeze({ session: "session", exit: "exit" });
52
+ var TerminalClientMessage = Object.freeze({ input: "input", resize: "resize" });
53
+ function webUrl({ url, token }) {
54
+ return `${url}/#token=${encodeURIComponent(token)}`;
55
+ }
56
+ function pairingUrl({ url, token }) {
57
+ return `${PAIRING_SCHEME}://pair?url=${encodeURIComponent(url)}&token=${encodeURIComponent(token)}`;
58
+ }
59
+
60
+ // ../../packages/agent-core/src/server.js
61
+ import { WebSocketServer } from "ws";
62
+
63
+ // ../../packages/agent-core/src/git.js
64
+ import { execFile } from "node:child_process";
65
+ import fs2 from "node:fs/promises";
66
+ import path2 from "node:path";
67
+
68
+ // ../../packages/agent-core/src/sandbox.js
69
+ import fsSync from "node:fs";
70
+ import fs from "node:fs/promises";
71
+ import path from "node:path";
72
+ var HttpError = class extends Error {
73
+ constructor(status2, message) {
74
+ super(message);
75
+ this.status = status2;
76
+ }
77
+ };
78
+ var Sandbox = class {
79
+ constructor(root2) {
80
+ this.root = path.resolve(root2);
81
+ this.realRoot = fsSync.realpathSync(this.root);
82
+ }
83
+ #containsReal(p) {
84
+ return p === this.realRoot || p.startsWith(this.realRoot + path.sep);
85
+ }
86
+ #containsLexically(p) {
87
+ return p === this.root || p.startsWith(this.root + path.sep);
88
+ }
89
+ /**
90
+ * Returns the absolute path for `p` (absolute, or relative to the root) after checking that
91
+ * it really lives inside the root. With `allowMissing`, the path may not exist yet
92
+ * (write/mkdir/rename targets); its nearest existing ancestor must still resolve inside.
93
+ */
94
+ async resolve(p, { allowMissing = false } = {}) {
95
+ return (await this.#check(p, allowMissing)).abs;
96
+ }
97
+ /** Like `resolve`, but refuses the root itself (deleting or renaming the shared folder). */
98
+ async resolveChild(p, { allowMissing = false } = {}) {
99
+ const { abs, real } = await this.#check(p, allowMissing);
100
+ if (real === this.realRoot) throw new HttpError(403, "cannot modify the shared folder itself");
101
+ return abs;
102
+ }
103
+ async #check(p, allowMissing) {
104
+ if (typeof p !== "string" || p === "") throw new HttpError(400, "missing path");
105
+ const abs = path.resolve(this.root, p);
106
+ let existing = abs;
107
+ let tail = "";
108
+ while (true) {
109
+ try {
110
+ const real = path.join(await fs.realpath(existing), tail);
111
+ if (!this.#containsReal(real)) throw outside();
112
+ return { abs, real };
113
+ } catch (e) {
114
+ if (e instanceof HttpError) throw e;
115
+ if (!this.#containsLexically(abs)) throw outside();
116
+ if (e.code !== "ENOENT" || !allowMissing) throw e;
117
+ const parent = path.dirname(existing);
118
+ if (parent === existing) throw e;
119
+ tail = path.join(path.basename(existing), tail);
120
+ existing = parent;
121
+ }
122
+ }
123
+ }
124
+ };
125
+ function outside() {
126
+ return new HttpError(403, "path is outside the shared folder");
127
+ }
128
+
129
+ // ../../packages/agent-core/src/git.js
130
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".build", "build", "dist", "DerivedData", "Pods", "vendor", ".venv", "target"]);
131
+ var MAX_SCAN_DEPTH = 4;
132
+ var MAX_DIFF_BYTES = 2 * 1024 * 1024;
133
+ function git(cwd, args, { timeout = 3e4, okCodes = [0], maxBuffer = 16 * 1024 * 1024 } = {}) {
134
+ return new Promise((resolve, reject) => {
135
+ execFile(
136
+ "git",
137
+ args,
138
+ {
139
+ cwd,
140
+ timeout,
141
+ maxBuffer,
142
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" }
143
+ },
144
+ (error, stdout, stderr) => {
145
+ const code = error ? typeof error.code === "number" ? error.code : null : 0;
146
+ if (code !== null && okCodes.includes(code)) return resolve({ stdout, stderr, code });
147
+ const message = (stderr || error?.message || "git failed").trim().split("\n").slice(-3).join("\n");
148
+ reject(Object.assign(new HttpError(error?.killed ? 504 : 409, message), { code: "EGIT" }));
149
+ }
150
+ );
151
+ });
152
+ }
153
+ async function findRepos(root2) {
154
+ const repos = [];
155
+ async function walk(dir, depth) {
156
+ let entries;
157
+ try {
158
+ entries = await fs2.readdir(dir, { withFileTypes: true });
159
+ } catch {
160
+ return;
161
+ }
162
+ if (entries.some((e) => e.name === ".git")) {
163
+ repos.push(dir);
164
+ return;
165
+ }
166
+ if (depth >= MAX_SCAN_DEPTH) return;
167
+ const subdirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP_DIRS.has(e.name));
168
+ await Promise.all(subdirs.map((e) => walk(path2.join(dir, e.name), depth + 1)));
169
+ }
170
+ await walk(root2, 0);
171
+ repos.sort((a, b) => a.split(path2.sep).length - b.split(path2.sep).length || a.localeCompare(b));
172
+ return Promise.all(
173
+ repos.map(async (repo) => ({
174
+ path: repo,
175
+ name: repo === root2 ? path2.basename(root2) : path2.relative(root2, repo),
176
+ branch: await currentBranch(repo)
177
+ }))
178
+ );
179
+ }
180
+ async function currentBranch(repo) {
181
+ try {
182
+ const { stdout } = await git(repo, ["symbolic-ref", "--short", "-q", "HEAD"], { okCodes: [0, 1] });
183
+ if (stdout.trim()) return stdout.trim();
184
+ const { stdout: sha } = await git(repo, ["rev-parse", "--short", "HEAD"], { okCodes: [0, 128] });
185
+ return sha.trim() ? `(${sha.trim()})` : null;
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+ async function status(repo) {
191
+ const { stdout } = await git(repo, ["status", "--porcelain=v2", "--branch", "--untracked-files=all", "-z"]);
192
+ const result = { branch: null, upstream: null, ahead: 0, behind: 0, files: [] };
193
+ const records = stdout.split("\0");
194
+ for (let i = 0; i < records.length; i++) {
195
+ const r = records[i];
196
+ if (!r) continue;
197
+ if (r.startsWith("# branch.head ")) {
198
+ const head = r.slice(14);
199
+ result.branch = head === "(detached)" ? null : head;
200
+ } else if (r.startsWith("# branch.upstream ")) {
201
+ result.upstream = r.slice(18);
202
+ } else if (r.startsWith("# branch.ab ")) {
203
+ const [a, b] = r.slice(12).split(" ");
204
+ result.ahead = Math.abs(parseInt(a, 10));
205
+ result.behind = Math.abs(parseInt(b, 10));
206
+ } else if (r.startsWith("1 ")) {
207
+ const parts = r.split(" ");
208
+ result.files.push(entry(parts[1], parts.slice(8).join(" ")));
209
+ } else if (r.startsWith("2 ")) {
210
+ const parts = r.split(" ");
211
+ result.files.push({ ...entry(parts[1], parts.slice(9).join(" ")), origPath: records[++i] });
212
+ } else if (r.startsWith("u ")) {
213
+ const parts = r.split(" ");
214
+ result.files.push({ ...entry(parts[1], parts.slice(10).join(" ")), conflicted: true });
215
+ } else if (r.startsWith("? ")) {
216
+ result.files.push({ path: r.slice(2), index: ".", worktree: "?" });
217
+ }
218
+ }
219
+ return result;
220
+ }
221
+ function entry(xy, filePath) {
222
+ return { path: filePath, index: xy[0], worktree: xy[1] };
223
+ }
224
+ async function diff(repo, file, { staged = false, untracked = false } = {}) {
225
+ const args = untracked ? ["diff", "--no-color", "--no-index", "--", "/dev/null", file] : ["diff", "--no-color", ...staged ? ["--cached"] : [], "--", file];
226
+ const { stdout } = await git(repo, args, { okCodes: [0, 1], maxBuffer: MAX_DIFF_BYTES * 2 });
227
+ return truncate(stdout);
228
+ }
229
+ async function stage(repo, files) {
230
+ await git(repo, ["add", "--", ...files]);
231
+ }
232
+ async function unstage(repo, files) {
233
+ try {
234
+ await git(repo, ["restore", "--staged", "--", ...files]);
235
+ } catch {
236
+ await git(repo, ["rm", "--cached", "-r", "-q", "--", ...files]);
237
+ }
238
+ }
239
+ async function discard(repo, files) {
240
+ const { files: changed } = await status(repo);
241
+ const untracked = files.filter((f) => changed.some((c) => c.path === f && c.worktree === "?"));
242
+ const tracked = files.filter((f) => !untracked.includes(f));
243
+ if (tracked.length) await git(repo, ["restore", "--worktree", "--", ...tracked]);
244
+ if (untracked.length) await git(repo, ["clean", "-f", "-q", "--", ...untracked]);
245
+ }
246
+ async function commit(repo, message) {
247
+ if (typeof message !== "string" || !message.trim()) throw new HttpError(400, "commit message is empty");
248
+ await git(repo, ["commit", "-q", "-m", message]);
249
+ return (await log(repo, 1))[0];
250
+ }
251
+ async function log(repo, limit = 50) {
252
+ const { stdout } = await git(repo, ["log", `-n${Math.min(Math.max(limit, 1), 500)}`, "-z", "--format=%H%x1f%h%x1f%s%x1f%an%x1f%at%x1f%D"], {
253
+ okCodes: [0, 128]
254
+ // 128: no commits yet
255
+ });
256
+ return stdout.split("\0").filter(Boolean).map((record) => {
257
+ const [hash, short, subject, author, at, refs] = record.split("");
258
+ return { hash, short, subject, author, date: parseInt(at, 10) * 1e3, refs: refs ? refs.split(", ") : [] };
259
+ });
260
+ }
261
+ async function show(repo, commitHash) {
262
+ if (!/^[0-9a-f]{4,64}$/i.test(commitHash || "")) throw new HttpError(400, "invalid commit");
263
+ const { stdout } = await git(repo, ["show", "--no-color", "--stat", "--patch", "--format=fuller", commitHash], {
264
+ maxBuffer: MAX_DIFF_BYTES * 2
265
+ });
266
+ return truncate(stdout);
267
+ }
268
+ async function pull(repo) {
269
+ const { stdout, stderr } = await git(repo, ["pull", "--ff-only"], { timeout: 12e4 });
270
+ return (stdout + stderr).trim();
271
+ }
272
+ async function push(repo) {
273
+ const { stdout, stderr } = await git(repo, ["push"], { timeout: 12e4 });
274
+ return (stdout + stderr).trim();
275
+ }
276
+ function truncate(text) {
277
+ if (Buffer.byteLength(text) <= MAX_DIFF_BYTES) return { text, truncated: false };
278
+ return { text: Buffer.from(text).subarray(0, MAX_DIFF_BYTES).toString("utf8"), truncated: true };
279
+ }
280
+
281
+ // ../../packages/agent-core/src/handoff.js
282
+ import { createHmac, timingSafeEqual } from "node:crypto";
283
+ var NONCE_WINDOW_MS = 5 * 6e4;
284
+ var REUSE_MIN_LIFETIME_MS = 12 * 36e5;
285
+ var TunnelHandoff = class {
286
+ /**
287
+ * @param {object} opts
288
+ * @param {string} opts.token the agent's bearer token (the HMAC key)
289
+ * @param {() => Promise<{token: string, expiresAt: number} | null>} opts.mint
290
+ */
291
+ constructor({ token, mint, now = Date.now }) {
292
+ this.key = token;
293
+ this.mint = mint;
294
+ this.now = now;
295
+ this.used = /* @__PURE__ */ new Map();
296
+ this.cached = null;
297
+ }
298
+ /** `nonce` is `<unix seconds>.<random>`; each one works once, within five minutes. */
299
+ verify(nonce, sig) {
300
+ if (typeof nonce !== "string" || typeof sig !== "string") return false;
301
+ const issued = Number(nonce.split(".")[0]) * 1e3;
302
+ const now = this.now();
303
+ if (!Number.isFinite(issued) || Math.abs(now - issued) > NONCE_WINDOW_MS) return false;
304
+ const want = Buffer.from(createHmac("sha256", this.key).update(nonce).digest("base64url"));
305
+ const got = Buffer.from(sig);
306
+ if (got.length !== want.length || !timingSafeEqual(got, want)) return false;
307
+ for (const [n, at] of this.used) if (now - at > 2 * NONCE_WINDOW_MS) this.used.delete(n);
308
+ if (this.used.has(nonce)) return false;
309
+ this.used.set(nonce, now);
310
+ return true;
311
+ }
312
+ /** A connect token, or null when the tunnel is anonymous (nothing to hand out). */
313
+ async issue() {
314
+ if (this.cached && this.cached.expiresAt - this.now() > REUSE_MIN_LIFETIME_MS) return this.cached;
315
+ this.cached = await this.mint();
316
+ return this.cached;
317
+ }
318
+ };
319
+
320
+ // ../../packages/agent-core/src/landing.js
321
+ var LANDING_HTML = `<!doctype html>
322
+ <html lang="en">
323
+ <head>
324
+ <meta charset="utf-8">
325
+ <meta name="viewport" content="width=device-width, initial-scale=1">
326
+ <meta name="referrer" content="no-referrer">
327
+ <title>Code Remote</title>
328
+ <style>
329
+ :root { color-scheme: light dark; --bg: #f5f5f7; --card: #fff; --text: #1d1d1f; --muted: #6e6e73; --accent: #0071e3; }
330
+ @media (prefers-color-scheme: dark) { :root { --bg: #000; --card: #1c1c1e; --text: #f5f5f7; --muted: #98989d; --accent: #2997ff; } }
331
+ * { box-sizing: border-box; }
332
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 16px;
333
+ background: var(--bg); color: var(--text); font: 17px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
334
+ main { width: 100%; max-width: 420px; background: var(--card); border-radius: 20px; padding: 28px 24px; text-align: center; }
335
+ h1 { font-size: 24px; margin: 0 0 8px; }
336
+ p { color: var(--muted); margin: 0 0 20px; }
337
+ a.button, button { display: block; width: 100%; padding: 14px; border-radius: 12px; border: 0; font: inherit; font-weight: 600;
338
+ text-decoration: none; cursor: pointer; }
339
+ a.button { background: var(--accent); color: #fff; margin-bottom: 10px; }
340
+ button { background: transparent; color: var(--accent); }
341
+ .hidden { display: none; }
342
+ </style>
343
+ </head>
344
+ <body>
345
+ <main>
346
+ <h1>Code Remote</h1>
347
+ <div id="ready" class="hidden">
348
+ <p>Open this machine in the Code Remote app on your iPhone.</p>
349
+ <a id="open" class="button" href="#">Open in Code Remote</a>
350
+ <button id="copy" type="button">Copy pairing link</button>
351
+ </div>
352
+ <p id="missing" class="hidden">This link is missing its access token. Copy the full link printed by <code>coderemote up</code>.</p>
353
+ </main>
354
+ <script>
355
+ const token = new URLSearchParams(location.hash.slice(1)).get('token');
356
+ if (token) {
357
+ const link = '${PAIRING_SCHEME}://pair?url=' + encodeURIComponent(location.origin) + '&token=' + encodeURIComponent(token);
358
+ document.getElementById('open').href = link;
359
+ document.getElementById('copy').onclick = async (e) => {
360
+ try { await navigator.clipboard.writeText(link); e.target.textContent = 'Copied'; }
361
+ catch { prompt('Pairing link', link); }
362
+ };
363
+ document.getElementById('ready').classList.remove('hidden');
364
+ } else {
365
+ document.getElementById('missing').classList.remove('hidden');
366
+ }
367
+ </script>
368
+ </body>
369
+ </html>
370
+ `;
371
+
372
+ // ../../packages/agent-core/src/sessions.js
373
+ import { randomUUID } from "node:crypto";
374
+ import os from "node:os";
375
+ import pty from "node-pty";
376
+ var SCROLLBACK_LIMIT = 256 * 1024;
377
+ var TerminalSession = class {
378
+ constructor({ cols, rows, cwd }) {
379
+ this.id = randomUUID();
380
+ this.createdAt = Date.now();
381
+ this.chunks = [];
382
+ this.bufferedBytes = 0;
383
+ this.clients = /* @__PURE__ */ new Set();
384
+ this.exitCode = null;
385
+ const shell = process.env.SHELL || (os.platform() === "win32" ? "powershell.exe" : "/bin/sh");
386
+ this.shell = shell;
387
+ this.pty = pty.spawn(shell, ["-l"], {
388
+ name: "xterm-256color",
389
+ cols,
390
+ rows,
391
+ cwd: cwd || os.homedir(),
392
+ env: { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" }
393
+ });
394
+ this.pty.onData((data) => this.#output(Buffer.from(data, "utf8")));
395
+ this.pty.onExit(({ exitCode }) => {
396
+ this.exitCode = exitCode;
397
+ for (const ws of this.clients) {
398
+ ws.send(JSON.stringify({ type: "exit", code: exitCode }));
399
+ ws.close(1e3, "session exited");
400
+ }
401
+ this.onExit?.();
402
+ });
403
+ }
404
+ #output(buf) {
405
+ this.chunks.push(buf);
406
+ this.bufferedBytes += buf.length;
407
+ while (this.bufferedBytes > SCROLLBACK_LIMIT && this.chunks.length > 1) {
408
+ this.bufferedBytes -= this.chunks.shift().length;
409
+ }
410
+ for (const ws of this.clients) ws.send(buf, { binary: true });
411
+ }
412
+ attach(ws) {
413
+ this.clients.add(ws);
414
+ const replay = Buffer.concat(this.chunks);
415
+ ws.send(JSON.stringify({ type: "session", id: this.id, shell: this.shell, replay: replay.length }));
416
+ if (replay.length) ws.send(replay, { binary: true });
417
+ ws.on("message", (data, isBinary) => {
418
+ if (isBinary) {
419
+ this.pty.write(data.toString("utf8"));
420
+ return;
421
+ }
422
+ let msg;
423
+ try {
424
+ msg = JSON.parse(data.toString("utf8"));
425
+ } catch {
426
+ return;
427
+ }
428
+ if (msg.type === "input" && typeof msg.data === "string") this.pty.write(msg.data);
429
+ else if (msg.type === "resize") this.resize(msg.cols, msg.rows);
430
+ });
431
+ ws.on("close", () => this.clients.delete(ws));
432
+ }
433
+ resize(cols, rows) {
434
+ if (Number.isInteger(cols) && Number.isInteger(rows) && cols > 0 && rows > 0) {
435
+ this.pty.resize(cols, rows);
436
+ }
437
+ }
438
+ kill() {
439
+ this.pty.kill();
440
+ }
441
+ toJSON() {
442
+ return {
443
+ id: this.id,
444
+ shell: this.shell,
445
+ pid: this.pty.pid,
446
+ title: this.pty.process,
447
+ createdAt: this.createdAt,
448
+ attached: this.clients.size
449
+ };
450
+ }
451
+ };
452
+ var SessionManager = class {
453
+ #sessions = /* @__PURE__ */ new Map();
454
+ create(opts) {
455
+ const session = new TerminalSession(opts);
456
+ session.onExit = () => this.#sessions.delete(session.id);
457
+ this.#sessions.set(session.id, session);
458
+ return session;
459
+ }
460
+ get(id) {
461
+ return this.#sessions.get(id);
462
+ }
463
+ list() {
464
+ return [...this.#sessions.values()].map((s) => s.toJSON());
465
+ }
466
+ killAll() {
467
+ for (const s of this.#sessions.values()) s.kill();
468
+ }
469
+ };
470
+
471
+ // ../../packages/agent-core/src/server.js
472
+ function kindOf(stat) {
473
+ if (stat.isDirectory()) return "dir";
474
+ if (stat.isFile()) return "file";
475
+ if (stat.isSymbolicLink()) return "symlink";
476
+ return "other";
477
+ }
478
+ async function describe(fullPath, name) {
479
+ const lst = await fs3.lstat(fullPath);
480
+ const entry2 = { name, type: kindOf(lst), size: lst.size, mtime: lst.mtimeMs };
481
+ if (lst.isSymbolicLink()) {
482
+ try {
483
+ entry2.target = kindOf(await fs3.stat(fullPath));
484
+ } catch {
485
+ entry2.target = "broken";
486
+ }
487
+ }
488
+ return entry2;
489
+ }
490
+ function sendJson(res, status2, body) {
491
+ const json = JSON.stringify(body);
492
+ res.writeHead(status2, { "content-type": "application/json", "content-length": Buffer.byteLength(json) });
493
+ res.end(json);
494
+ }
495
+ async function readJson(req) {
496
+ const chunks = [];
497
+ for await (const c of req) chunks.push(c);
498
+ try {
499
+ return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
500
+ } catch {
501
+ throw new HttpError(400, "invalid json");
502
+ }
503
+ }
504
+ function createAgentServer({ token, root: root2, tunnelToken = async () => null }) {
505
+ const handoff = new TunnelHandoff({ token, mint: tunnelToken });
506
+ const sandbox = new Sandbox(root2);
507
+ const sessions = new SessionManager();
508
+ const expected = Buffer.from(`Bearer ${token}`);
509
+ function authorized(req) {
510
+ const got = Buffer.from(req.headers.authorization || "");
511
+ return got.length === expected.length && timingSafeEqual2(got, expected);
512
+ }
513
+ async function repoOf(p) {
514
+ const repo = await sandbox.resolve(p);
515
+ try {
516
+ await fs3.access(path3.join(repo, ".git"));
517
+ } catch {
518
+ throw new HttpError(400, "not a git repository");
519
+ }
520
+ return repo;
521
+ }
522
+ async function filesIn(repo, files) {
523
+ if (!Array.isArray(files) || !files.length || files.some((f) => typeof f !== "string" || !f || path3.isAbsolute(f))) {
524
+ throw new HttpError(400, "files must be a non-empty list of paths relative to the repository");
525
+ }
526
+ return Promise.all(
527
+ files.map(async (f) => {
528
+ const abs = await sandbox.resolve(path3.join(repo, f), { allowMissing: true });
529
+ const rel = path3.relative(repo, abs);
530
+ if (!rel || rel.startsWith("..")) throw new HttpError(403, "path is outside the repository");
531
+ return rel;
532
+ })
533
+ );
534
+ }
535
+ const routes = {
536
+ [Routes.info]: async () => ({
537
+ protocol: PROTOCOL_VERSION,
538
+ hostname: os2.hostname(),
539
+ platform: os2.platform(),
540
+ root: sandbox.root,
541
+ user: os2.userInfo().username,
542
+ shell: process.env.SHELL || null
543
+ }),
544
+ [Routes.fsList]: async (url) => {
545
+ const dir = await sandbox.resolve(url.searchParams.get("path"));
546
+ const names = await fs3.readdir(dir);
547
+ const entries = (await Promise.all(names.map((n) => describe(path3.join(dir, n), n).catch(() => null)))).filter(Boolean);
548
+ return { path: dir, entries };
549
+ },
550
+ [Routes.fsStat]: async (url) => {
551
+ const p = await sandbox.resolve(url.searchParams.get("path"));
552
+ return { path: p, ...await describe(p, path3.basename(p)) };
553
+ },
554
+ [Routes.fsRead]: async (url, req, res) => {
555
+ const p = await sandbox.resolve(url.searchParams.get("path"));
556
+ const stat = await fs3.stat(p);
557
+ if (!stat.isFile()) throw new HttpError(400, "not a file");
558
+ res.writeHead(200, { "content-type": "application/octet-stream", "content-length": stat.size });
559
+ await pipeline(createReadStream(p), res);
560
+ },
561
+ [Routes.fsWrite]: async (url, req) => {
562
+ const p = await sandbox.resolveChild(url.searchParams.get("path"), { allowMissing: true });
563
+ const tmp = `${p}.vsremote-${process.pid}-${Date.now()}.tmp`;
564
+ try {
565
+ await pipeline(req, createWriteStream(tmp));
566
+ await fs3.rename(tmp, p);
567
+ } catch (e) {
568
+ await fs3.rm(tmp, { force: true });
569
+ throw e;
570
+ }
571
+ return describe(p, path3.basename(p));
572
+ },
573
+ [Routes.fsMkdir]: async (url) => {
574
+ await fs3.mkdir(await sandbox.resolve(url.searchParams.get("path"), { allowMissing: true }), { recursive: true });
575
+ return {};
576
+ },
577
+ [Routes.fsRename]: async (url, req) => {
578
+ const { from, to } = await readJson(req);
579
+ await fs3.rename(await sandbox.resolveChild(from), await sandbox.resolveChild(to, { allowMissing: true }));
580
+ return {};
581
+ },
582
+ [Routes.fsDelete]: async (url) => {
583
+ await fs3.rm(await sandbox.resolveChild(url.searchParams.get("path")), { recursive: true });
584
+ return {};
585
+ },
586
+ [Routes.gitRepos]: async () => ({ repos: await findRepos(sandbox.root) }),
587
+ [Routes.gitStatus]: async (url) => status(await repoOf(url.searchParams.get("repo"))),
588
+ [Routes.gitDiff]: async (url) => {
589
+ const repo = await repoOf(url.searchParams.get("repo"));
590
+ const [file] = await filesIn(repo, [url.searchParams.get("path")]);
591
+ return diff(repo, file, {
592
+ staged: url.searchParams.get("staged") === "1",
593
+ untracked: url.searchParams.get("untracked") === "1"
594
+ });
595
+ },
596
+ [Routes.gitStage]: async (url, req) => {
597
+ const { repo: r, files } = await readJson(req);
598
+ const repo = await repoOf(r);
599
+ await stage(repo, await filesIn(repo, files));
600
+ return status(repo);
601
+ },
602
+ [Routes.gitUnstage]: async (url, req) => {
603
+ const { repo: r, files } = await readJson(req);
604
+ const repo = await repoOf(r);
605
+ await unstage(repo, await filesIn(repo, files));
606
+ return status(repo);
607
+ },
608
+ [Routes.gitDiscard]: async (url, req) => {
609
+ const { repo: r, files } = await readJson(req);
610
+ const repo = await repoOf(r);
611
+ await discard(repo, await filesIn(repo, files));
612
+ return status(repo);
613
+ },
614
+ [Routes.gitCommit]: async (url, req) => {
615
+ const { repo: r, message } = await readJson(req);
616
+ return commit(await repoOf(r), message);
617
+ },
618
+ [Routes.gitLog]: async (url) => ({
619
+ commits: await log(await repoOf(url.searchParams.get("repo")), parseInt(url.searchParams.get("limit"), 10) || 50)
620
+ }),
621
+ [Routes.gitShow]: async (url) => show(await repoOf(url.searchParams.get("repo")), url.searchParams.get("commit")),
622
+ [Routes.gitPull]: async (url, req) => ({ output: await pull(await repoOf((await readJson(req)).repo)) }),
623
+ [Routes.gitPush]: async (url, req) => ({ output: await push(await repoOf((await readJson(req)).repo)) }),
624
+ [Routes.termList]: async () => ({ sessions: sessions.list() }),
625
+ [Routes.tunnelToken]: async () => {
626
+ const issued = await handoff.issue();
627
+ if (!issued) throw new HttpError(404, "this tunnel is anonymous");
628
+ return issued;
629
+ },
630
+ [Routes.termKill]: async (url) => {
631
+ const s = sessions.get(url.searchParams.get("id"));
632
+ if (!s) throw new HttpError(404, "no such session");
633
+ s.kill();
634
+ return {};
635
+ }
636
+ };
637
+ const server = http.createServer(async (req, res) => {
638
+ const url = new URL(req.url, "http://localhost");
639
+ if (req.method === "GET" && url.pathname === "/") {
640
+ res.writeHead(200, {
641
+ "content-type": "text/html; charset=utf-8",
642
+ "cache-control": "no-store",
643
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'"
644
+ });
645
+ return res.end(LANDING_HTML);
646
+ }
647
+ if (req.method === "GET" && url.pathname === TUNNEL_HANDOFF_PATH) return handoffRedirect(url, res);
648
+ if (!authorized(req)) return sendJson(res, 401, { error: "unauthorized" });
649
+ const handler = routes[`${req.method} ${url.pathname}`];
650
+ if (!handler) return sendJson(res, 404, { error: "not found" });
651
+ try {
652
+ const body = await handler(url, req, res);
653
+ if (body !== void 0) sendJson(res, 200, body);
654
+ } catch (e) {
655
+ const status2 = e.status || (e.code === "ENOENT" ? 404 : e.code === "EACCES" || e.code === "EPERM" ? 403 : 500);
656
+ if (!res.headersSent) sendJson(res, status2, { error: e.message, code: e.code });
657
+ else res.destroy(e);
658
+ }
659
+ });
660
+ async function handoffRedirect(url, res) {
661
+ const state = url.searchParams.get("state") || "";
662
+ if (!/^[A-Za-z0-9_-]{8,128}$/.test(state) || !handoff.verify(url.searchParams.get("nonce"), url.searchParams.get("sig"))) {
663
+ return sendJson(res, 403, { error: "invalid or expired sign-in link" });
664
+ }
665
+ let issued;
666
+ try {
667
+ issued = await handoff.issue();
668
+ } catch (e) {
669
+ return sendJson(res, 502, { error: `could not issue a tunnel token: ${e.message}` });
670
+ }
671
+ if (!issued) return sendJson(res, 404, { error: "this tunnel is anonymous" });
672
+ const target = new URL(`${PAIRING_SCHEME}://${TUNNEL_AUTH_CALLBACK_HOST}`);
673
+ target.searchParams.set("token", issued.token);
674
+ target.searchParams.set("expires", String(issued.expiresAt));
675
+ target.searchParams.set("state", state);
676
+ res.writeHead(302, { location: target.href, "cache-control": "no-store", "referrer-policy": "no-referrer" });
677
+ res.end();
678
+ }
679
+ const wss = new WebSocketServer({ noServer: true });
680
+ server.on("upgrade", async (req, socket, head) => {
681
+ const url = new URL(req.url, "http://localhost");
682
+ if (url.pathname !== TERMINAL_WS_PATH || !authorized(req)) {
683
+ socket.end("HTTP/1.1 401 Unauthorized\r\n\r\n");
684
+ return;
685
+ }
686
+ const id = url.searchParams.get("id");
687
+ let session;
688
+ if (id) {
689
+ session = sessions.get(id);
690
+ if (!session) {
691
+ socket.end("HTTP/1.1 404 Not Found\r\n\r\n");
692
+ return;
693
+ }
694
+ } else {
695
+ try {
696
+ session = sessions.create({
697
+ cols: parseInt(url.searchParams.get("cols"), 10) || 80,
698
+ rows: parseInt(url.searchParams.get("rows"), 10) || 24,
699
+ // New shells start in the shared folder unless a subfolder is asked for.
700
+ cwd: await sandbox.resolve(url.searchParams.get("cwd") || sandbox.root)
701
+ });
702
+ } catch (e) {
703
+ socket.end(`HTTP/1.1 ${e.status || (e.code === "ENOENT" ? 404 : 500)} Error\r
704
+ \r
705
+ `);
706
+ return;
707
+ }
708
+ }
709
+ wss.handleUpgrade(req, socket, head, (ws) => session.attach(ws));
710
+ });
711
+ server.on("close", () => sessions.killAll());
712
+ server.killAllSessions = () => sessions.killAll();
713
+ return server;
714
+ }
715
+
716
+ // src/registry.js
717
+ import fs4 from "node:fs";
718
+ import path4 from "node:path";
719
+ var AgentRegistry = class {
720
+ constructor(dir) {
721
+ this.dir = dir;
722
+ }
723
+ register({ port: port2, root: root2, url }) {
724
+ fs4.mkdirSync(this.dir, { recursive: true, mode: 448 });
725
+ const file = this.#file(port2);
726
+ fs4.writeFileSync(file, JSON.stringify({ pid: process.pid, port: port2, root: root2, url, startedAt: Date.now() }), { mode: 384 });
727
+ const cleanup = () => {
728
+ try {
729
+ if (JSON.parse(fs4.readFileSync(file, "utf8")).pid === process.pid) fs4.rmSync(file);
730
+ } catch {
731
+ }
732
+ };
733
+ process.on("exit", cleanup);
734
+ return cleanup;
735
+ }
736
+ /** Live agents; entries left behind by crashed processes are removed. */
737
+ list() {
738
+ let names = [];
739
+ try {
740
+ names = fs4.readdirSync(this.dir).filter((n) => n.endsWith(".json"));
741
+ } catch {
742
+ return [];
743
+ }
744
+ const agents = [];
745
+ for (const name of names) {
746
+ const file = path4.join(this.dir, name);
747
+ try {
748
+ const agent = JSON.parse(fs4.readFileSync(file, "utf8"));
749
+ process.kill(agent.pid, 0);
750
+ agents.push(agent);
751
+ } catch {
752
+ fs4.rmSync(file, { force: true });
753
+ }
754
+ }
755
+ return agents.sort((a, b) => a.port - b.port);
756
+ }
757
+ #file(port2) {
758
+ return path4.join(this.dir, `${port2}.json`);
759
+ }
760
+ };
761
+
762
+ // src/spinner.js
763
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
764
+ var dim = (s) => `\x1B[2m${s}\x1B[22m`;
765
+ var cyan = (s) => `\x1B[36m${s}\x1B[39m`;
766
+ var green = (s) => `\x1B[32m${s}\x1B[39m`;
767
+ var red = (s) => `\x1B[31m${s}\x1B[39m`;
768
+ var Spinner = class {
769
+ constructor(stream = process.stderr) {
770
+ this.stream = stream;
771
+ this.tty = Boolean(stream.isTTY);
772
+ this.text = null;
773
+ process.on("exit", () => this.stop());
774
+ }
775
+ start(text) {
776
+ this.stop();
777
+ this.text = text;
778
+ this.startedAt = Date.now();
779
+ if (!this.tty) {
780
+ this.stream.write(` ${text}...
781
+ `);
782
+ return;
783
+ }
784
+ this.stream.write("\x1B[?25l");
785
+ this.onSigint = () => {
786
+ this.stop();
787
+ process.exit(130);
788
+ };
789
+ process.once("SIGINT", this.onSigint);
790
+ let frame = 0;
791
+ this.render(FRAMES[0]);
792
+ this.timer = setInterval(() => this.render(FRAMES[++frame % FRAMES.length]), 80);
793
+ }
794
+ succeed() {
795
+ this.finish(green("\u2714"));
796
+ }
797
+ fail() {
798
+ this.finish(red("\u2716"));
799
+ }
800
+ /** Clears the spinner line (e.g. before an error message or an interactive prompt). */
801
+ stop() {
802
+ if (!this.text) return;
803
+ clearInterval(this.timer);
804
+ if (this.onSigint) process.removeListener("SIGINT", this.onSigint);
805
+ this.onSigint = null;
806
+ if (this.tty) this.stream.write("\r\x1B[2K\x1B[?25h");
807
+ this.text = null;
808
+ }
809
+ render(frame) {
810
+ this.stream.write(`\r\x1B[2K ${cyan(frame)} ${this.text} ${dim(this.elapsed())}`);
811
+ }
812
+ finish(mark) {
813
+ if (!this.text) return;
814
+ const line = ` ${mark} ${this.text} ${dim(this.elapsed())}`;
815
+ const tty = this.tty;
816
+ this.stop();
817
+ if (tty) this.stream.write(`${line}
818
+ `);
819
+ }
820
+ elapsed() {
821
+ return `${((Date.now() - this.startedAt) / 1e3).toFixed(1)}s`;
822
+ }
823
+ };
824
+
825
+ // bin/coderemote.js
826
+ var run = promisify(execFile2);
827
+ var CONFIG_DIR = path5.join(os3.homedir(), ".coderemote");
828
+ var LEGACY_CONFIG_DIR = path5.join(os3.homedir(), ".vsremote");
829
+ if (!fs5.existsSync(CONFIG_DIR) && fs5.existsSync(LEGACY_CONFIG_DIR)) fs5.renameSync(LEGACY_CONFIG_DIR, CONFIG_DIR);
830
+ var CONFIG_FILE = path5.join(CONFIG_DIR, "config.json");
831
+ var DEFAULT_PORT = 31580;
832
+ var spinner = new Spinner();
833
+ function loadConfig() {
834
+ let cfg2 = {};
835
+ try {
836
+ cfg2 = JSON.parse(fs5.readFileSync(CONFIG_FILE, "utf8"));
837
+ } catch {
838
+ }
839
+ if (!cfg2.token) {
840
+ cfg2.token = randomBytes(32).toString("base64url");
841
+ saveConfig(cfg2);
842
+ }
843
+ return cfg2;
844
+ }
845
+ function saveConfig(cfg2) {
846
+ fs5.mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
847
+ fs5.writeFileSync(CONFIG_FILE, JSON.stringify(cfg2, null, 2), { mode: 384 });
848
+ }
849
+ function listen(token, port2, root2, tunnelToken) {
850
+ const server = createAgentServer({ token, root: root2, tunnelToken });
851
+ return new Promise((resolve, reject) => {
852
+ server.once("error", reject);
853
+ server.listen(port2, "127.0.0.1", () => resolve(server));
854
+ });
855
+ }
856
+ async function devtunnel(...args) {
857
+ try {
858
+ const { stdout } = await run("devtunnel", args);
859
+ return { ok: true, out: stdout };
860
+ } catch (e) {
861
+ if (e.code === "ENOENT") {
862
+ spinner.stop();
863
+ console.error("devtunnel CLI not found. Install it: brew install --cask devtunnel");
864
+ process.exit(1);
865
+ }
866
+ return { ok: false, out: `${e.stdout || ""}${e.stderr || ""}` };
867
+ }
868
+ }
869
+ var LOGIN_NEEDED = /not logged in|login token expired|login required|please log ?in/i;
870
+ async function login(reason) {
871
+ spinner.stop();
872
+ console.error(`Dev tunnels: ${reason.trim()}`);
873
+ if (!process.stdin.isTTY) {
874
+ console.error("Run: devtunnel user login -g (or -d for a Microsoft account), then try again.");
875
+ process.exit(1);
876
+ }
877
+ console.error("Signing in again with GitHub (devtunnel user login -g)...\n");
878
+ const code = await new Promise((resolve) => {
879
+ spawn("devtunnel", ["user", "login", "-g"], { stdio: "inherit" }).on("exit", resolve);
880
+ });
881
+ if (code !== 0) {
882
+ console.error("\nLogin failed. Run `devtunnel user login -g` (or -d) yourself, then try again.");
883
+ process.exit(1);
884
+ }
885
+ }
886
+ async function tunnelUser() {
887
+ let who = await devtunnel("user", "show");
888
+ if (!who.ok || LOGIN_NEEDED.test(who.out)) {
889
+ await login(who.out || "not logged in");
890
+ who = await devtunnel("user", "show");
891
+ }
892
+ const m = who.out.match(/Logged in as (\S+) using (\S+?)\.?\s*$/m);
893
+ return m ? `${m[1]} (${m[2]})` : who.out.trim();
894
+ }
895
+ function applyAccess(tunnelId, anonymous2) {
896
+ return anonymous2 ? devtunnel("access", "create", tunnelId, "--anonymous") : devtunnel("access", "reset", tunnelId);
897
+ }
898
+ async function ensureTunnel(cfg2, port2, anonymous2) {
899
+ if (cfg2.tunnelId) {
900
+ const [updated, access] = await Promise.all([
901
+ devtunnel("update", cfg2.tunnelId, "--expiration", "30d"),
902
+ applyAccess(cfg2.tunnelId, anonymous2)
903
+ ]);
904
+ if (updated.ok) {
905
+ if (!access.ok) throw new Error(`devtunnel access failed:
906
+ ${access.out}`);
907
+ return cfg2.tunnelId;
908
+ }
909
+ if (!/not found/i.test(updated.out)) throw new Error(`devtunnel update failed:
910
+ ${updated.out}`);
911
+ }
912
+ const createArgs = ["create", "--expiration", "30d", "-d", `coderemote ${os3.hostname()}`, "--json"];
913
+ if (anonymous2) createArgs.push("--allow-anonymous");
914
+ const created = await devtunnel(...createArgs);
915
+ if (!created.ok) throw new Error(`devtunnel create failed:
916
+ ${created.out}`);
917
+ const tunnelId = JSON.parse(created.out.slice(created.out.indexOf("{"))).tunnel?.tunnelId;
918
+ if (!tunnelId) throw new Error(`could not parse tunnel id from:
919
+ ${created.out}`);
920
+ const portRes = await devtunnel("port", "create", tunnelId, "-p", String(port2), "--protocol", "http");
921
+ if (!portRes.ok) throw new Error(`devtunnel port create failed:
922
+ ${portRes.out}`);
923
+ cfg2.tunnelId = tunnelId;
924
+ saveConfig(cfg2);
925
+ return tunnelId;
926
+ }
927
+ async function connectToken(tunnelId) {
928
+ const res = await devtunnel("token", tunnelId, "--scopes", "connect", "--json");
929
+ if (!res.ok) throw new Error(res.out.trim());
930
+ const body = JSON.parse(res.out.slice(res.out.indexOf("{")));
931
+ const expiresAt = Date.parse(body.expiration.replace(" UTC", "Z").replace(" ", "T"));
932
+ if (!body.token || !Number.isFinite(expiresAt)) throw new Error(`unexpected devtunnel token output:
933
+ ${res.out}`);
934
+ return { token: body.token, expiresAt };
935
+ }
936
+ function hostTunnel(tunnelId, port2) {
937
+ return new Promise((resolve, reject) => {
938
+ const child = spawn("devtunnel", ["host", tunnelId], { stdio: ["ignore", "pipe", "pipe"] });
939
+ let log2 = "";
940
+ const onData = (d) => {
941
+ log2 += d;
942
+ const m = log2.match(new RegExp(`https://\\S*-${port2}\\.\\S*devtunnels\\.ms\\S*`));
943
+ if (m) resolve({ child, url: m[0].replace(/\/$/, "") });
944
+ };
945
+ child.stdout.on("data", onData);
946
+ child.stderr.on("data", onData);
947
+ child.on("exit", (code) => reject(new Error(`devtunnel host exited (${code}):
948
+ ${log2}`)));
949
+ });
950
+ }
951
+ function printPairing(url, token, access) {
952
+ const pair = pairingUrl({ url, token });
953
+ if (access) console.log(` Access: ${access}`);
954
+ console.log(`
955
+ Web: ${webUrl({ url, token })}`);
956
+ console.log(` App: ${pair}
957
+ `);
958
+ console.log(" Scan with the iPhone Camera, or open either link on the phone:\n");
959
+ qrcode.generate(pair, { small: true });
960
+ console.log(
961
+ anonymous ? "\n The links contain your access token. Anyone with them can use this machine." : "\n The links contain your access token; the phone or browser must also sign in as you."
962
+ );
963
+ console.log(" Run `coderemote rotate-token` to revoke every paired device.\n");
964
+ }
965
+ var USAGE = `Usage: coderemote <command> [options]
966
+
967
+ up share this folder through a dev tunnel and print the pairing QR;
968
+ the phone or browser must sign in with your dev tunnels account
969
+ serve share this folder on 127.0.0.1 only (no tunnel)
970
+ status list running agents and their terminal sessions
971
+ kill <id>... | --all kill terminal sessions (an id prefix is enough)
972
+ stop [--all] stop the agent for this folder (or every agent)
973
+ rotate-token revoke every paired device
974
+
975
+ Options:
976
+ --root DIR folder to share (default: current folder)
977
+ --port N local port (default: ${DEFAULT_PORT})
978
+ --allow-anonymous up: skip the sign-in, the pairing token alone grants access
979
+ (alias --no-auth)`;
980
+ var HELP_FLAGS = ["help", "--help", "-help", "-h"];
981
+ var argv = process.argv.slice(2);
982
+ var [cmd, ...rest] = argv.length === 0 || argv.some((a) => HELP_FLAGS.includes(a)) ? ["help"] : argv;
983
+ var flag = (name) => {
984
+ const i = rest.indexOf(name);
985
+ return i >= 0 ? rest[i + 1] : void 0;
986
+ };
987
+ var anonymous = rest.includes("--allow-anonymous") || rest.includes("--no-auth");
988
+ var port = flag("--port") ? parseInt(flag("--port"), 10) : DEFAULT_PORT;
989
+ var root = path5.resolve(flag("--root") ?? process.cwd());
990
+ var cfg = loadConfig();
991
+ var registry = new AgentRegistry(path5.join(CONFIG_DIR, "agents"));
992
+ async function start({ tunnel }) {
993
+ const running = registry.list().find((a) => a.port === port);
994
+ if (running) {
995
+ console.error(`An agent already runs on port ${port} for ${running.root} (pid ${running.pid}).`);
996
+ console.error("Stop it with `coderemote stop --all`, or pick another port with --port.");
997
+ process.exit(1);
998
+ }
999
+ let server;
1000
+ let tunnelId;
1001
+ const tunnelToken = async () => tunnel && !anonymous && tunnelId ? connectToken(tunnelId) : null;
1002
+ spinner.start("Starting agent");
1003
+ try {
1004
+ server = await listen(cfg.token, port, root, tunnelToken);
1005
+ } catch (e) {
1006
+ spinner.fail();
1007
+ if (e.code !== "EADDRINUSE") throw e;
1008
+ console.error(`Port ${port} is already in use by another process.`);
1009
+ console.error(`See what holds it with: lsof -nP -iTCP:${port} -sTCP:LISTEN`);
1010
+ console.error("Stop that process, or pick another port with --port.");
1011
+ process.exit(1);
1012
+ }
1013
+ let child;
1014
+ let access;
1015
+ let url = `http://127.0.0.1:${port}`;
1016
+ if (tunnel) {
1017
+ spinner.succeed();
1018
+ spinner.start("Checking dev tunnels sign-in");
1019
+ const user = await tunnelUser();
1020
+ spinner.succeed();
1021
+ spinner.start("Connecting to the relay");
1022
+ const known = cfg.tunnelId;
1023
+ let hosting = known ? hostTunnel(known, port) : null;
1024
+ hosting?.catch(() => {
1025
+ });
1026
+ tunnelId = await ensureTunnel(cfg, port, anonymous);
1027
+ if (tunnelId !== known) {
1028
+ hosting?.then((h) => h.child.kill(), () => {
1029
+ });
1030
+ hosting = hostTunnel(tunnelId, port);
1031
+ }
1032
+ ({ child, url } = await hosting);
1033
+ access = anonymous ? "anyone with the link (no sign-in, --allow-anonymous)" : `sign-in required as ${user}`;
1034
+ }
1035
+ spinner.succeed();
1036
+ registry.register({ port, root, url });
1037
+ const shutdown = () => {
1038
+ child?.kill();
1039
+ server.killAllSessions();
1040
+ server.close();
1041
+ process.exit(0);
1042
+ };
1043
+ process.on("SIGINT", shutdown);
1044
+ process.on("SIGTERM", shutdown);
1045
+ return { url, access };
1046
+ }
1047
+ async function agentApi(agent, method, route, query = {}) {
1048
+ const url = new URL(route, `http://127.0.0.1:${agent.port}`);
1049
+ for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
1050
+ const res = await fetch(url, { method, headers: { authorization: `Bearer ${cfg.token}` } });
1051
+ const body = await res.json();
1052
+ if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
1053
+ return body;
1054
+ }
1055
+ async function sessionsOf(agent) {
1056
+ try {
1057
+ return (await agentApi(agent, "GET", "/api/term")).sessions;
1058
+ } catch (e) {
1059
+ console.error(` could not reach agent on port ${agent.port}: ${e.message}`);
1060
+ return [];
1061
+ }
1062
+ }
1063
+ function ago(ms) {
1064
+ const s = Math.round((Date.now() - ms) / 1e3);
1065
+ if (s < 60) return `${s}s`;
1066
+ if (s < 3600) return `${Math.round(s / 60)}m`;
1067
+ if (s < 86400) return `${Math.round(s / 3600)}h`;
1068
+ return `${Math.round(s / 86400)}d`;
1069
+ }
1070
+ switch (cmd) {
1071
+ case "serve": {
1072
+ await start({ tunnel: false });
1073
+ console.log(`coderemote agent on http://127.0.0.1:${port}
1074
+ Sharing: ${root}
1075
+ Token: ${cfg.token}`);
1076
+ break;
1077
+ }
1078
+ case "up": {
1079
+ let started;
1080
+ try {
1081
+ started = await start({ tunnel: true });
1082
+ } catch (e) {
1083
+ spinner.fail();
1084
+ throw e;
1085
+ }
1086
+ const { url, access } = started;
1087
+ console.log(`
1088
+ Sharing: ${root}`);
1089
+ printPairing(url, cfg.token, access);
1090
+ break;
1091
+ }
1092
+ case "status":
1093
+ case "ps": {
1094
+ const agents = registry.list();
1095
+ if (!agents.length) {
1096
+ console.log("No agent is running. Start one with `coderemote up`.");
1097
+ break;
1098
+ }
1099
+ for (const agent of agents) {
1100
+ console.log(`${agent.root} (pid ${agent.pid}, port ${agent.port}, up ${ago(agent.startedAt)})`);
1101
+ if (agent.url) console.log(` ${agent.url}`);
1102
+ const sessions = await sessionsOf(agent);
1103
+ if (!sessions.length) console.log(" no terminal sessions");
1104
+ for (const s of sessions) {
1105
+ const attached = s.attached ? `${s.attached} attached` : "detached";
1106
+ console.log(` ${s.id.slice(0, 8)} ${s.title.padEnd(12)} pid ${String(s.pid).padEnd(7)} ${ago(s.createdAt).padEnd(4)} ${attached}`);
1107
+ }
1108
+ }
1109
+ break;
1110
+ }
1111
+ case "kill": {
1112
+ const all = rest.includes("--all");
1113
+ const ids = rest.filter((a) => !a.startsWith("--"));
1114
+ if (!all && !ids.length) {
1115
+ console.error("Usage: coderemote kill <session-id>... | --all");
1116
+ process.exit(1);
1117
+ }
1118
+ let killed = 0;
1119
+ const matched = /* @__PURE__ */ new Set();
1120
+ for (const agent of registry.list()) {
1121
+ for (const s of await sessionsOf(agent)) {
1122
+ const hit = ids.find((id) => s.id.startsWith(id));
1123
+ if (!all && !hit) continue;
1124
+ if (hit) matched.add(hit);
1125
+ await agentApi(agent, "DELETE", "/api/term", { id: s.id });
1126
+ console.log(`killed ${s.id.slice(0, 8)} (${s.title}) in ${agent.root}`);
1127
+ killed++;
1128
+ }
1129
+ }
1130
+ for (const id of ids.filter((id2) => !matched.has(id2))) console.error(`no session matches ${id}`);
1131
+ if (all && !killed) console.log("No terminal sessions to kill.");
1132
+ if (ids.length && matched.size < ids.length) process.exit(1);
1133
+ break;
1134
+ }
1135
+ case "stop": {
1136
+ const agents = registry.list();
1137
+ const targets = rest.includes("--all") ? agents : agents.filter((a) => a.root === root);
1138
+ if (!targets.length) {
1139
+ console.log(agents.length ? `No agent is sharing ${root}. Use \`coderemote stop --all\`.` : "No agent is running.");
1140
+ break;
1141
+ }
1142
+ for (const agent of targets) {
1143
+ process.kill(agent.pid, "SIGTERM");
1144
+ console.log(`stopped agent for ${agent.root} (pid ${agent.pid})`);
1145
+ }
1146
+ break;
1147
+ }
1148
+ case "rotate-token": {
1149
+ cfg.token = randomBytes(32).toString("base64url");
1150
+ saveConfig(cfg);
1151
+ console.log("Token rotated. Re-pair your devices, and restart running agents.");
1152
+ break;
1153
+ }
1154
+ case "help":
1155
+ console.log(USAGE);
1156
+ break;
1157
+ default:
1158
+ console.error(`Unknown command: ${cmd}
1159
+
1160
+ ${USAGE}`);
1161
+ process.exit(1);
1162
+ }