@stim-cli/server 0.0.0 → 1.10.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,1913 @@
1
+ #!/usr/bin/env node
2
+ import { a as MAX_LOG_TAIL, i as LOG_SOURCES, r as LOG_LEVELS, t as CAPABILITIES } from "./protocol-B3k9Ykuu.mjs";
3
+ import { createRequire } from "node:module";
4
+ import { accessSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
5
+ import { homedir, hostname, tmpdir } from "node:os";
6
+ import { parseArgs } from "node:util";
7
+ import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
8
+ import { basename, delimiter, dirname, join } from "node:path";
9
+ import { createHash, randomBytes } from "node:crypto";
10
+ import { configDir, withDirLock } from "@stim-cli/core";
11
+ import { compileGrep, isJsonObject, loadConfig, readJsonObject } from "@stim-cli/core/state";
12
+ import { createServer } from "node:http";
13
+ import { isIP } from "node:net";
14
+ import { WebSocketServer } from "ws";
15
+ import { createInterface } from "node:readline";
16
+ import { connect } from "node:http2";
17
+ //#region src/environment.ts
18
+ function parseEnvironment(output) {
19
+ const environment = {};
20
+ for (const entry of output.split("\0")) {
21
+ const equals = entry.indexOf("=");
22
+ if (equals > 0) environment[entry.slice(0, equals)] = entry.slice(equals + 1);
23
+ }
24
+ return environment;
25
+ }
26
+ /**
27
+ * The login shell's environment, as Stim Desktop captures it: a process started by launchd lacks the
28
+ * PATH entries and variables such as ANDROID_HOME that the shell profile sets. The shell writes to a
29
+ * file because a background process started by a profile can keep a pipe open after the shell exits.
30
+ */
31
+ function loginShellEnvironment() {
32
+ const dir = mkdtempSync(join(tmpdir(), "stim-server-env-"));
33
+ const file = join(dir, "env");
34
+ try {
35
+ spawnSync(process.env.SHELL || "/bin/zsh", [
36
+ "-lic",
37
+ "command env -0 > \"$1\"",
38
+ "stim-server",
39
+ file
40
+ ], {
41
+ stdio: "ignore",
42
+ timeout: 15e3
43
+ });
44
+ const environment = parseEnvironment(readFileSync(file, "utf8"));
45
+ return Object.keys(environment).length ? environment : null;
46
+ } catch {
47
+ return null;
48
+ } finally {
49
+ rmSync(dir, {
50
+ recursive: true,
51
+ force: true
52
+ });
53
+ }
54
+ }
55
+ function bundledStim() {
56
+ const manifest = createRequire(import.meta.url).resolve("stim/package.json");
57
+ const pkg = JSON.parse(readFileSync(manifest, "utf8"));
58
+ return {
59
+ cli: join(dirname(manifest), pkg.bin.stim),
60
+ version: pkg.version
61
+ };
62
+ }
63
+ //#endregion
64
+ //#region src/registry.ts
65
+ const PAIRING_TTL_MS = 3e5;
66
+ function serverDir() {
67
+ return join(configDir(), "server");
68
+ }
69
+ function devicesFile() {
70
+ return join(serverDir(), "devices.json");
71
+ }
72
+ function pairingFile() {
73
+ return join(serverDir(), "pairing.json");
74
+ }
75
+ function hashToken(token) {
76
+ return createHash("sha256").update(token).digest("hex");
77
+ }
78
+ function newToken() {
79
+ return randomBytes(32).toString("base64url");
80
+ }
81
+ function parseIdentity(value) {
82
+ if (!isJsonObject(value)) return null;
83
+ if (value.kind === "local") return { kind: "local" };
84
+ const { nodeId, nodeName, user } = value;
85
+ if (value.kind === "tailnet" && typeof nodeId === "string" && typeof nodeName === "string" && typeof user === "string") return {
86
+ kind: "tailnet",
87
+ nodeId,
88
+ nodeName,
89
+ user
90
+ };
91
+ return null;
92
+ }
93
+ function parseDevice(value) {
94
+ if (!isJsonObject(value)) return null;
95
+ const { id, name, tokenHash, pairedAt, lastSeenAt } = value;
96
+ const identity = parseIdentity(value.identity);
97
+ if (typeof id !== "string" || typeof name !== "string" || typeof tokenHash !== "string" || !identity) return null;
98
+ if (typeof pairedAt !== "string") return null;
99
+ const capabilities = Array.isArray(value.capabilities) ? CAPABILITIES.filter((capability) => value.capabilities.includes(capability)) : [];
100
+ return {
101
+ id,
102
+ name,
103
+ tokenHash,
104
+ identity,
105
+ pairedAt,
106
+ lastSeenAt: typeof lastSeenAt === "string" ? lastSeenAt : null,
107
+ capabilities
108
+ };
109
+ }
110
+ function readDevices() {
111
+ const devices = readJsonObject(devicesFile())?.devices;
112
+ return Array.isArray(devices) ? devices.flatMap((entry) => parseDevice(entry) ?? []) : [];
113
+ }
114
+ function readPairings() {
115
+ const tokens = readJsonObject(pairingFile())?.tokens;
116
+ if (!Array.isArray(tokens)) return [];
117
+ return tokens.flatMap((entry) => isJsonObject(entry) && typeof entry.tokenHash === "string" && typeof entry.expiresAt === "string" ? [{
118
+ tokenHash: entry.tokenHash,
119
+ expiresAt: entry.expiresAt
120
+ }] : []);
121
+ }
122
+ function writeJson(file, value) {
123
+ const temporary = join(dirname(file), `.${basename(file)}.${process.pid}.tmp`);
124
+ try {
125
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
126
+ renameSync(temporary, file);
127
+ } catch (error) {
128
+ rmSync(temporary, { force: true });
129
+ throw error;
130
+ }
131
+ }
132
+ function transaction(fn) {
133
+ return withDirLock(join(serverDir(), "registry.lock"), fn, { ensureParent: () => mkdirSync(serverDir(), {
134
+ recursive: true,
135
+ mode: 448
136
+ }) });
137
+ }
138
+ function unexpired(record, now) {
139
+ return Date.parse(record.expiresAt) > now;
140
+ }
141
+ function createPairingToken(now = Date.now()) {
142
+ const token = newToken();
143
+ const expiresAt = new Date(now + PAIRING_TTL_MS).toISOString();
144
+ transaction(() => {
145
+ const pending = readPairings().filter((record) => unexpired(record, now));
146
+ writeJson(pairingFile(), {
147
+ version: 1,
148
+ tokens: [...pending, {
149
+ tokenHash: hashToken(token),
150
+ expiresAt
151
+ }]
152
+ });
153
+ });
154
+ return {
155
+ token,
156
+ expiresAt
157
+ };
158
+ }
159
+ function sameNode(a, b) {
160
+ if (a.kind === "local" || b.kind === "local") return a.kind === b.kind;
161
+ return a.nodeId === b.nodeId;
162
+ }
163
+ function spendPairingToken(token, name, identity, now = Date.now()) {
164
+ return transaction(() => {
165
+ const pairings = readPairings();
166
+ const tokenHash = hashToken(token);
167
+ const match = pairings.find((record) => record.tokenHash === tokenHash);
168
+ const pending = pairings.filter((record) => record !== match && unexpired(record, now));
169
+ if (pending.length !== pairings.length) writeJson(pairingFile(), {
170
+ version: 1,
171
+ tokens: pending
172
+ });
173
+ if (!match) return {
174
+ ok: false,
175
+ reason: "pairing-unknown"
176
+ };
177
+ if (!unexpired(match, now)) return {
178
+ ok: false,
179
+ reason: "pairing-expired"
180
+ };
181
+ const deviceToken = newToken();
182
+ const at = new Date(now).toISOString();
183
+ const device = {
184
+ id: randomBytes(4).toString("hex"),
185
+ name,
186
+ tokenHash: hashToken(deviceToken),
187
+ identity,
188
+ pairedAt: at,
189
+ lastSeenAt: at,
190
+ capabilities: ["read"]
191
+ };
192
+ writeJson(devicesFile(), {
193
+ version: 1,
194
+ devices: [...readDevices(), device]
195
+ });
196
+ return {
197
+ ok: true,
198
+ device,
199
+ deviceToken
200
+ };
201
+ });
202
+ }
203
+ function authenticateDevice(token, identity, now = Date.now()) {
204
+ return transaction(() => {
205
+ const devices = readDevices();
206
+ const tokenHash = hashToken(token);
207
+ const device = devices.find((entry) => entry.tokenHash === tokenHash);
208
+ if (!device) return {
209
+ ok: false,
210
+ reason: "device-unknown"
211
+ };
212
+ if (!sameNode(device.identity, identity)) return {
213
+ ok: false,
214
+ reason: "node-mismatch"
215
+ };
216
+ device.lastSeenAt = new Date(now).toISOString();
217
+ writeJson(devicesFile(), {
218
+ version: 1,
219
+ devices
220
+ });
221
+ return {
222
+ ok: true,
223
+ device
224
+ };
225
+ });
226
+ }
227
+ function revokeDevice(id) {
228
+ return transaction(() => {
229
+ const devices = readDevices();
230
+ const remaining = devices.filter((device) => device.id !== id);
231
+ if (remaining.length === devices.length) return false;
232
+ writeJson(devicesFile(), {
233
+ version: 1,
234
+ devices: remaining
235
+ });
236
+ return true;
237
+ });
238
+ }
239
+ //#endregion
240
+ //#region src/stim-command.ts
241
+ const KILL_GRACE_MS = 1e3;
242
+ function terminate(child) {
243
+ if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
244
+ child.kill("SIGTERM");
245
+ const timer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
246
+ return new Promise((resolve) => {
247
+ const done = () => {
248
+ clearTimeout(timer);
249
+ resolve();
250
+ };
251
+ child.once("exit", done);
252
+ child.once("close", done);
253
+ });
254
+ }
255
+ function runStim(stimCli, env, args, cwd, limits) {
256
+ const child = spawn(process.execPath, [stimCli, ...args], {
257
+ cwd,
258
+ env,
259
+ stdio: [
260
+ "ignore",
261
+ "pipe",
262
+ "pipe"
263
+ ]
264
+ });
265
+ const label = `stim ${args[0]}`;
266
+ let cancelled = false;
267
+ let failure = null;
268
+ const chunks = [];
269
+ let size = 0;
270
+ let stderr = "";
271
+ const fail = (message) => {
272
+ if (failure) return;
273
+ failure = message;
274
+ terminate(child);
275
+ };
276
+ const timer = setTimeout(() => fail(`${label} did not finish within ${limits.timeoutMs / 1e3} s.`), limits.timeoutMs);
277
+ child.stdout.on("data", (chunk) => {
278
+ size += chunk.length;
279
+ if (size > limits.maxOutputBytes) return fail(`${label} printed more than ${limits.maxOutputBytes} bytes.`);
280
+ chunks.push(chunk);
281
+ });
282
+ child.stderr.setEncoding("utf8");
283
+ child.stderr.on("data", (chunk) => {
284
+ stderr = (stderr + chunk).slice(-2e3);
285
+ });
286
+ return {
287
+ outcome: new Promise((resolve) => {
288
+ const settle = (result) => {
289
+ clearTimeout(timer);
290
+ if (!cancelled) resolve(result);
291
+ };
292
+ child.on("error", (error) => settle({
293
+ ok: false,
294
+ message: `${label} could not start (${error.message}).`
295
+ }));
296
+ child.on("close", (code, signal) => {
297
+ if (failure) return settle({
298
+ ok: false,
299
+ message: failure
300
+ });
301
+ if (code === 0) return settle({
302
+ ok: true,
303
+ stdout: Buffer.concat(chunks).toString("utf8")
304
+ });
305
+ const detail = stderr.trim();
306
+ settle({
307
+ ok: false,
308
+ message: `${label} exited (${signal ?? `code ${code}`})${detail ? `: ${detail}` : ""}`
309
+ });
310
+ });
311
+ }),
312
+ cancel: () => {
313
+ cancelled = true;
314
+ clearTimeout(timer);
315
+ return terminate(child);
316
+ }
317
+ };
318
+ }
319
+ //#endregion
320
+ //#region src/feed.ts
321
+ var Feed = class {
322
+ listeners = /* @__PURE__ */ new Set();
323
+ kept = [];
324
+ child;
325
+ ended;
326
+ constructor(spec, stimCli, env, ended) {
327
+ this.ended = ended;
328
+ const child = spawn(process.execPath, [stimCli, ...spec.args], {
329
+ cwd: spec.cwd,
330
+ env,
331
+ stdio: [
332
+ "ignore",
333
+ "pipe",
334
+ "pipe"
335
+ ]
336
+ });
337
+ this.child = child;
338
+ let stderr = "";
339
+ child.stderr?.setEncoding("utf8");
340
+ child.stderr?.on("data", (chunk) => {
341
+ stderr = (stderr + chunk).slice(-2e3);
342
+ });
343
+ createInterface({ input: child.stdout }).on("line", (line) => {
344
+ if (this.child !== child) return;
345
+ let value;
346
+ try {
347
+ value = JSON.parse(line);
348
+ } catch {
349
+ return;
350
+ }
351
+ if (!isJsonObject(value)) return;
352
+ this.kept.push(value);
353
+ if (this.kept.length > spec.keep) this.kept.shift();
354
+ for (const listener of this.listeners) listener.item(value);
355
+ });
356
+ const exited = (reason) => {
357
+ if (this.child !== child) return;
358
+ this.child = null;
359
+ this.ended();
360
+ const message = `${spec.label} ${reason}${stderr.trim() ? `: ${stderr.trim()}` : ""}`;
361
+ const listeners = [...this.listeners];
362
+ this.listeners.clear();
363
+ for (const listener of listeners) listener.failed(message);
364
+ };
365
+ child.on("error", (error) => exited(`could not start (${error.message})`));
366
+ child.on("close", (code, signal) => exited(`exited (${signal ?? `code ${code}`})`));
367
+ }
368
+ add(listener) {
369
+ this.listeners.add(listener);
370
+ for (const value of this.kept) listener.item(value);
371
+ return () => {
372
+ if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
373
+ };
374
+ }
375
+ stop() {
376
+ const child = this.child;
377
+ if (!child) return Promise.resolve();
378
+ this.child = null;
379
+ this.listeners.clear();
380
+ this.ended();
381
+ return terminate(child);
382
+ }
383
+ };
384
+ var FeedPool = class {
385
+ feeds = /* @__PURE__ */ new Map();
386
+ stimCli;
387
+ env;
388
+ constructor(stimCli, env) {
389
+ this.stimCli = stimCli;
390
+ this.env = env;
391
+ }
392
+ subscribe(spec, listener) {
393
+ const key = JSON.stringify([spec.cwd, spec.args]);
394
+ let feed = this.feeds.get(key);
395
+ if (!feed) {
396
+ const created = new Feed(spec, this.stimCli, this.env, () => {
397
+ if (this.feeds.get(key) === created) this.feeds.delete(key);
398
+ });
399
+ this.feeds.set(key, created);
400
+ feed = created;
401
+ }
402
+ return feed.add(listener);
403
+ }
404
+ async close() {
405
+ await Promise.all([...this.feeds.values()].map((feed) => feed.stop()));
406
+ }
407
+ };
408
+ //#endregion
409
+ //#region src/frames.ts
410
+ const DEFAULT_FRAME_LIMITS = {
411
+ toolTimeoutMs: 3e4,
412
+ slowCaptureMs: 3e3,
413
+ failureBackoffMs: 3e3,
414
+ maxConsecutiveFailures: 3
415
+ };
416
+ const MIN_INTERVAL_MS = 200;
417
+ const MAX_INTERVAL_MS = 1e3;
418
+ const MAX_CAPTURES = 2;
419
+ const MAX_EDGE = 1280;
420
+ const JPEG_QUALITY = 70;
421
+ function deviceKey(device) {
422
+ return device.platform === "ios" ? `ios:${device.udid}` : `android:${device.serial}`;
423
+ }
424
+ function ownedDevice(payload, target, attached) {
425
+ const slot = target.slot ?? "default";
426
+ if (!Array.isArray(payload.environments)) return "stim status printed a payload without environments.";
427
+ const environment = payload.environments.find((candidate) => candidate.path === target.workspace);
428
+ if (!environment) return `${target.workspace} is not a Stim workspace on this Mac.`;
429
+ const devices = slot === "default" ? {
430
+ ios: environment.ios,
431
+ android: environment.android
432
+ } : environment.slots?.find((candidate) => candidate.slot === slot);
433
+ const where = `${target.platform} in slot ${slot} of ${target.workspace}`;
434
+ if (target.platform === "ios") {
435
+ const sim = devices?.ios;
436
+ if (!sim?.owned) return `No simulator Stim owns runs ${where}.`;
437
+ if (sim.state !== "Booted") return `The simulator for ${where} is ${sim.state}, not booted.`;
438
+ return {
439
+ platform: "ios",
440
+ udid: sim.udid
441
+ };
442
+ }
443
+ const emulator = devices?.android;
444
+ if (!emulator?.owned || emulator.physical) return `No emulator Stim owns runs ${where}.`;
445
+ if (emulator.state === "unknown" && attached?.startsWith("android:")) return {
446
+ platform: "android",
447
+ serial: attached.slice(8)
448
+ };
449
+ if (emulator.state !== "detected" || !emulator.serial) return `The emulator for ${where} is not running.`;
450
+ return {
451
+ platform: "android",
452
+ serial: emulator.serial
453
+ };
454
+ }
455
+ function jpegSize(bytes) {
456
+ if (bytes[0] !== 255 || bytes[1] !== 216) return null;
457
+ let offset = 2;
458
+ while (offset + 9 <= bytes.length) {
459
+ if (bytes[offset] !== 255) return null;
460
+ const marker = bytes[offset + 1];
461
+ if (marker === 255) {
462
+ offset += 1;
463
+ continue;
464
+ }
465
+ if (marker === 1 || marker >= 208 && marker <= 216) {
466
+ offset += 2;
467
+ continue;
468
+ }
469
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) return {
470
+ height: bytes.readUInt16BE(offset + 5),
471
+ width: bytes.readUInt16BE(offset + 7)
472
+ };
473
+ offset += 2 + bytes.readUInt16BE(offset + 2);
474
+ }
475
+ return null;
476
+ }
477
+ /** A rejection with `transient: true` is a timeout on a machine that is merely busy, not a broken capturer. */
478
+ function timeoutError(message) {
479
+ return Object.assign(new Error(message), { transient: true });
480
+ }
481
+ function isTransient(error) {
482
+ return error instanceof Error && error.transient === true;
483
+ }
484
+ function runTool(file, args, env, running, timeoutMs) {
485
+ return new Promise((resolve, reject) => {
486
+ const child = spawn(file, args, {
487
+ env,
488
+ stdio: [
489
+ "ignore",
490
+ "pipe",
491
+ "pipe"
492
+ ]
493
+ });
494
+ running.add(child);
495
+ child.once("close", () => running.delete(child));
496
+ const chunks = [];
497
+ let stderr = "";
498
+ const timer = setTimeout(() => {
499
+ terminate(child);
500
+ reject(timeoutError(`${file} ${args[0]} did not finish within ${timeoutMs / 1e3} s.`));
501
+ }, timeoutMs);
502
+ child.stdout.on("data", (chunk) => chunks.push(chunk));
503
+ child.stderr.setEncoding("utf8");
504
+ child.stderr.on("data", (chunk) => {
505
+ stderr = (stderr + chunk).slice(-1e3);
506
+ });
507
+ child.on("error", (error) => {
508
+ clearTimeout(timer);
509
+ reject(/* @__PURE__ */ new Error(`${file} could not start (${error.message}).`));
510
+ });
511
+ child.on("close", (code) => {
512
+ clearTimeout(timer);
513
+ if (code === 0) resolve(Buffer.concat(chunks));
514
+ else reject(Object.assign(/* @__PURE__ */ new Error(`${file} ${args.join(" ")} exited (code ${code}): ${stderr.trim()}`), { stderr }));
515
+ });
516
+ });
517
+ }
518
+ async function stopTools(running, tmp) {
519
+ await Promise.all([...running].map((child) => terminate(child)));
520
+ if (tmp) rmSync(tmp, {
521
+ recursive: true,
522
+ force: true
523
+ });
524
+ }
525
+ /**
526
+ * Without `--display`, simctl captures the first panel it finds, which on a foldable simulator (iPhone Duo)
527
+ * can be the unlit one and comes back black; `primary` is the panel CoreDevice reports as primary. A simctl
528
+ * that rejects `primary` gets the default display instead.
529
+ */
530
+ function simulatorCapturer(udid, env, limits) {
531
+ let tmp = null;
532
+ let display = ["--display=primary"];
533
+ let closed = false;
534
+ const running = /* @__PURE__ */ new Set();
535
+ const screenshot = (output) => runTool("xcrun", [
536
+ "simctl",
537
+ "io",
538
+ udid,
539
+ "screenshot",
540
+ "--type=jpeg",
541
+ ...display,
542
+ output
543
+ ], env, running, limits.toolTimeoutMs);
544
+ return {
545
+ capture: async () => {
546
+ tmp ??= mkdtempSync(join(serverDir(), "frames-"));
547
+ const output = join(tmp, "frame.jpg");
548
+ try {
549
+ await screenshot(output);
550
+ } catch (error) {
551
+ if (closed || !display.length || !/display/i.test(error.stderr ?? "")) throw error;
552
+ display = [];
553
+ await screenshot(output);
554
+ }
555
+ const jpeg = readFileSync(output);
556
+ return {
557
+ raw: jpeg,
558
+ jpeg: async () => jpeg
559
+ };
560
+ },
561
+ close: () => {
562
+ closed = true;
563
+ return stopTools(running, tmp);
564
+ }
565
+ };
566
+ }
567
+ /**
568
+ * The emulator writes `pid_<pid>.ini` to this directory on macOS when it was started with `-grpc`, which
569
+ * Stim passes when it boots an owned AVD (#1064).
570
+ */
571
+ function emulatorEndpoint(env, serial) {
572
+ const console = /^emulator-(\d+)$/.exec(serial)?.[1];
573
+ if (!console || !env.HOME) return null;
574
+ const dir = join(env.HOME, "Library/Caches/TemporaryItems/avd/running");
575
+ let names;
576
+ try {
577
+ names = readdirSync(dir);
578
+ } catch {
579
+ return null;
580
+ }
581
+ for (const name of names) {
582
+ const pid = /^pid_(\d+)\.ini$/.exec(name)?.[1];
583
+ if (!pid) continue;
584
+ try {
585
+ process.kill(Number(pid), 0);
586
+ } catch {
587
+ continue;
588
+ }
589
+ let text;
590
+ try {
591
+ text = readFileSync(join(dir, name), "utf8");
592
+ } catch {
593
+ continue;
594
+ }
595
+ const values = new Map(text.split(/\r?\n/).flatMap((line) => {
596
+ const at = line.indexOf("=");
597
+ return at > 0 ? [[line.slice(0, at), line.slice(at + 1)]] : [];
598
+ }));
599
+ const grpcPort = Number(values.get("grpc.port"));
600
+ if (values.get("port.serial") === console && Number.isInteger(grpcPort) && grpcPort > 0) return {
601
+ grpcPort,
602
+ token: values.get("grpc.token") ?? null
603
+ };
604
+ }
605
+ return null;
606
+ }
607
+ function varint(value) {
608
+ const out = [];
609
+ let rest = value;
610
+ while (rest >= 128) {
611
+ out.push(rest & 127 | 128);
612
+ rest = Math.floor(rest / 128);
613
+ }
614
+ out.push(rest);
615
+ return out;
616
+ }
617
+ function* protoFields(bytes) {
618
+ let offset = 0;
619
+ const readVarint = () => {
620
+ let result = 0;
621
+ let scale = 1;
622
+ for (;;) {
623
+ if (offset >= bytes.length) throw new Error("truncated protobuf");
624
+ const byte = bytes[offset++];
625
+ result += (byte & 127) * scale;
626
+ if (byte < 128) return result;
627
+ scale *= 128;
628
+ }
629
+ };
630
+ while (offset < bytes.length) {
631
+ const key = readVarint();
632
+ const field = Math.floor(key / 8);
633
+ const type = key % 8;
634
+ if (type === 0) yield {
635
+ field,
636
+ varint: readVarint()
637
+ };
638
+ else if (type === 2) {
639
+ const length = readVarint();
640
+ if (offset + length > bytes.length) throw new Error("truncated protobuf");
641
+ yield {
642
+ field,
643
+ bytes: bytes.subarray(offset, offset + length)
644
+ };
645
+ offset += length;
646
+ } else if (type === 1) offset += 8;
647
+ else if (type === 5) offset += 4;
648
+ else throw new Error(`unsupported protobuf wire type ${type}`);
649
+ }
650
+ }
651
+ /** `ImageFormat` and `Image` in the emulator's emulator_controller.proto; format 0 is PNG. */
652
+ function screenshotRequest() {
653
+ const message = Buffer.from([
654
+ 24,
655
+ ...varint(MAX_EDGE),
656
+ 32,
657
+ ...varint(MAX_EDGE)
658
+ ]);
659
+ const header = Buffer.alloc(5);
660
+ header.writeUInt32BE(message.length, 1);
661
+ return Buffer.concat([header, message]);
662
+ }
663
+ function screenshotImage(body) {
664
+ if (body.length < 5 || body[0] !== 0) throw new Error("getScreenshot returned no message.");
665
+ const message = body.subarray(5, 5 + body.readUInt32BE(1));
666
+ for (const entry of protoFields(message)) if (entry.field === 4 && "bytes" in entry) return Buffer.from(entry.bytes);
667
+ throw new Error("getScreenshot returned no image.");
668
+ }
669
+ function emulatorCapturer(serial, env, limits) {
670
+ let session = null;
671
+ let tmp = null;
672
+ const running = /* @__PURE__ */ new Set();
673
+ const call = (endpoint) => new Promise((resolve, reject) => {
674
+ if (!session || session.closed || session.destroyed) {
675
+ session = connect(`http://127.0.0.1:${endpoint.grpcPort}`);
676
+ session.on("error", () => {});
677
+ }
678
+ const request = session.request({
679
+ ":method": "POST",
680
+ ":path": "/android.emulation.control.EmulatorController/getScreenshot",
681
+ "content-type": "application/grpc",
682
+ te: "trailers",
683
+ ...endpoint.token ? { authorization: `Bearer ${endpoint.token}` } : {}
684
+ });
685
+ const chunks = [];
686
+ let status;
687
+ let message;
688
+ let timedOut = false;
689
+ const record = (headers) => {
690
+ if (headers["grpc-status"] !== void 0) status = String(headers["grpc-status"]);
691
+ if (headers["grpc-message"] !== void 0) message = String(headers["grpc-message"]);
692
+ };
693
+ request.setTimeout(limits.toolTimeoutMs, () => {
694
+ timedOut = true;
695
+ request.close();
696
+ });
697
+ request.on("response", record);
698
+ request.on("trailers", record);
699
+ request.on("data", (chunk) => chunks.push(chunk));
700
+ request.on("error", (error) => {
701
+ const text = `getScreenshot failed on ${serial}: ${error.message}`;
702
+ reject(timedOut ? timeoutError(text) : new Error(text));
703
+ });
704
+ request.on("close", () => {
705
+ if (status === "0") return resolve(Buffer.concat(chunks));
706
+ const text = `getScreenshot failed on ${serial}: ${message ?? `status ${status ?? "missing"}`}`;
707
+ reject(timedOut ? timeoutError(text) : new Error(text));
708
+ });
709
+ request.end(screenshotRequest());
710
+ });
711
+ return {
712
+ capture: async () => {
713
+ const endpoint = emulatorEndpoint(env, serial);
714
+ if (!endpoint) throw new Error(`${serial} has no gRPC endpoint. Frames appear after Stim next boots this emulator.`);
715
+ const png = screenshotImage(await call(endpoint));
716
+ return {
717
+ raw: png,
718
+ jpeg: async () => {
719
+ tmp ??= mkdtempSync(join(serverDir(), "frames-"));
720
+ const input = join(tmp, "frame.png");
721
+ const output = join(tmp, "frame.jpg");
722
+ writeFileSync(input, png);
723
+ await runTool("sips", [
724
+ ...[
725
+ "-s",
726
+ "format",
727
+ "jpeg",
728
+ "-s",
729
+ "formatOptions",
730
+ String(JPEG_QUALITY)
731
+ ],
732
+ input,
733
+ "--out",
734
+ output
735
+ ], env, running, limits.toolTimeoutMs);
736
+ return readFileSync(output);
737
+ }
738
+ };
739
+ },
740
+ close: () => {
741
+ session?.close();
742
+ return stopTools(running, tmp);
743
+ }
744
+ };
745
+ }
746
+ var Limiter = class {
747
+ active = 0;
748
+ waiting = [];
749
+ max;
750
+ constructor(max) {
751
+ this.max = max;
752
+ }
753
+ async run(task) {
754
+ if (this.active >= this.max) await new Promise((resolve) => this.waiting.push(resolve));
755
+ else this.active++;
756
+ try {
757
+ return await task();
758
+ } finally {
759
+ const next = this.waiting.shift();
760
+ if (next) next();
761
+ else this.active--;
762
+ }
763
+ }
764
+ };
765
+ var FrameSource = class {
766
+ listeners = /* @__PURE__ */ new Set();
767
+ last = null;
768
+ lastHash = null;
769
+ interval = MIN_INTERVAL_MS;
770
+ timer = null;
771
+ stopped = false;
772
+ delayed = false;
773
+ consecutiveFailures = 0;
774
+ capturer;
775
+ limiter;
776
+ limits;
777
+ ended;
778
+ constructor(capturer, limiter, limits, ended) {
779
+ this.capturer = capturer;
780
+ this.limiter = limiter;
781
+ this.limits = limits;
782
+ this.ended = ended;
783
+ this.tick();
784
+ }
785
+ add(listener) {
786
+ this.listeners.add(listener);
787
+ if (this.last) listener.frame(this.last);
788
+ if (this.delayed) listener.delayed(true);
789
+ return () => {
790
+ if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
791
+ };
792
+ }
793
+ stop() {
794
+ if (this.stopped) return Promise.resolve();
795
+ this.stopped = true;
796
+ if (this.timer) clearTimeout(this.timer);
797
+ this.listeners.clear();
798
+ this.ended();
799
+ return this.capturer.close();
800
+ }
801
+ setDelayed(delayed) {
802
+ if (this.delayed === delayed) return;
803
+ this.delayed = delayed;
804
+ for (const listener of this.listeners) listener.delayed(delayed);
805
+ }
806
+ async tick() {
807
+ this.timer = null;
808
+ let took = 0;
809
+ try {
810
+ const changed = await this.limiter.run(async () => {
811
+ if (this.stopped) return false;
812
+ const started = Date.now();
813
+ const capture = await this.capturer.capture();
814
+ const hash = createHash("sha256").update(capture.raw).digest("hex");
815
+ took = Date.now() - started;
816
+ if (hash === this.lastHash || this.stopped) return false;
817
+ const jpeg = await capture.jpeg();
818
+ const size = jpegSize(jpeg);
819
+ if (!size) throw new Error("The screenshot is not a JPEG image.");
820
+ this.lastHash = hash;
821
+ this.last = {
822
+ ...size,
823
+ capturedAt: new Date(started).toISOString(),
824
+ data: jpeg.toString("base64")
825
+ };
826
+ took = Date.now() - started;
827
+ return true;
828
+ });
829
+ if (this.stopped) return;
830
+ this.consecutiveFailures = 0;
831
+ this.setDelayed(took > this.limits.slowCaptureMs);
832
+ if (changed) for (const listener of this.listeners) listener.frame(this.last);
833
+ this.interval = changed ? MIN_INTERVAL_MS : Math.min(this.interval * 2, MAX_INTERVAL_MS);
834
+ this.timer = setTimeout(() => void this.tick(), Math.max(this.interval - took, took));
835
+ } catch (error) {
836
+ if (this.stopped) return;
837
+ if (isTransient(error) && this.consecutiveFailures + 1 < this.limits.maxConsecutiveFailures) {
838
+ this.consecutiveFailures++;
839
+ this.setDelayed(true);
840
+ this.timer = setTimeout(() => void this.tick(), this.limits.failureBackoffMs);
841
+ return;
842
+ }
843
+ const listeners = [...this.listeners];
844
+ this.stop();
845
+ for (const listener of listeners) listener.failed(error.message);
846
+ }
847
+ }
848
+ };
849
+ /**
850
+ * One capture loop per device, shared by its subscribers and stopped with the last of them. A loop sends a
851
+ * frame only when the screen changed; it captures up to 5 times a second while the screen changes and backs
852
+ * off to once a second while it does not, and spends at most half of its time capturing. At most two
853
+ * captures run at once across all devices.
854
+ */
855
+ var FramePool = class {
856
+ sources = /* @__PURE__ */ new Map();
857
+ limiter = new Limiter(MAX_CAPTURES);
858
+ env;
859
+ limits;
860
+ constructor(env, limits = DEFAULT_FRAME_LIMITS) {
861
+ this.env = env;
862
+ this.limits = limits;
863
+ }
864
+ subscribe(device, listener) {
865
+ const key = deviceKey(device);
866
+ let source = this.sources.get(key);
867
+ if (!source) {
868
+ const created = new FrameSource(device.platform === "ios" ? simulatorCapturer(device.udid, this.env, this.limits) : emulatorCapturer(device.serial, this.env, this.limits), this.limiter, this.limits, () => {
869
+ if (this.sources.get(key) === created) this.sources.delete(key);
870
+ });
871
+ this.sources.set(key, created);
872
+ source = created;
873
+ }
874
+ return source.add(listener);
875
+ }
876
+ async close() {
877
+ await Promise.all([...this.sources.values()].map((source) => source.stop()));
878
+ }
879
+ };
880
+ //#endregion
881
+ //#region src/logs.ts
882
+ function parseLogFilter(params) {
883
+ if (!isJsonObject(params) || typeof params.workspace !== "string") return { error: "params.workspace is required." };
884
+ const { sources, level, slot, grep, errors, tail } = params;
885
+ if (sources !== void 0 && (!Array.isArray(sources) || sources.length === 0 || !sources.every((source) => LOG_SOURCES.includes(source)))) return { error: `sources must be a non-empty list of ${LOG_SOURCES.join(", ")}.` };
886
+ if (level !== void 0 && !LOG_LEVELS.includes(level)) return { error: `level must be one of ${LOG_LEVELS.join(", ")}.` };
887
+ if (slot !== void 0 && (typeof slot !== "string" || slot === "")) return { error: "slot must be a slot name." };
888
+ if (grep !== void 0 && (typeof grep !== "string" || compileGrep(grep).error)) return { error: "grep must be a valid regular expression." };
889
+ if (errors !== void 0 && typeof errors !== "boolean") return { error: "errors must be true or false." };
890
+ if (tail !== void 0 && (!Number.isInteger(tail) || tail < 1 || tail > 5e3)) return { error: `tail must be an integer from 1 to ${MAX_LOG_TAIL}.` };
891
+ return { filter: {
892
+ workspace: params.workspace,
893
+ ...sources ? { sources } : {},
894
+ ...level && level !== "debug" ? { level } : {},
895
+ ...slot ? { slot } : {},
896
+ ...grep ? { grep } : {},
897
+ ...errors ? { errors } : {},
898
+ tail: tail ?? 5e3
899
+ } };
900
+ }
901
+ function logArgs(filter, follow) {
902
+ const args = [
903
+ "logs",
904
+ "--json",
905
+ ...follow ? ["--follow"] : [],
906
+ `--tail=${filter.tail ?? 5e3}`
907
+ ];
908
+ if (filter.sources) args.push("--source", ...LOG_SOURCES.filter((source) => filter.sources.includes(source)));
909
+ if (filter.slot) args.push(`--slot=${filter.slot}`);
910
+ if (filter.level) args.push(`--level=${filter.level}`);
911
+ if (filter.grep) args.push(`--grep=${filter.grep}`);
912
+ if (filter.errors) args.push("--errors");
913
+ return args;
914
+ }
915
+ const BATCH_MS = 100;
916
+ const MAX_BATCH = 500;
917
+ var LogBatcher = class {
918
+ pending = [];
919
+ timer = null;
920
+ stopped = false;
921
+ sink;
922
+ limits;
923
+ constructor(sink, limits) {
924
+ this.sink = sink;
925
+ this.limits = limits;
926
+ }
927
+ push(record) {
928
+ if (this.stopped) return;
929
+ this.pending.push(record);
930
+ if (this.pending.length > this.limits.maxPendingRecords) {
931
+ this.stop();
932
+ queueMicrotask(this.sink.overflow);
933
+ return;
934
+ }
935
+ this.timer ??= setTimeout(() => this.flush(false), BATCH_MS);
936
+ }
937
+ flush(force) {
938
+ if (this.timer) clearTimeout(this.timer);
939
+ this.timer = null;
940
+ while (this.pending.length) {
941
+ if (!force && this.sink.bufferedBytes() >= this.limits.maxBufferedBytes) break;
942
+ this.sink.send(this.pending.splice(0, MAX_BATCH));
943
+ }
944
+ if (this.pending.length && !this.stopped) this.timer = setTimeout(() => this.flush(false), BATCH_MS);
945
+ }
946
+ stop() {
947
+ this.stopped = true;
948
+ if (this.timer) clearTimeout(this.timer);
949
+ this.timer = null;
950
+ this.pending = [];
951
+ }
952
+ };
953
+ //#endregion
954
+ //#region src/tailscale.ts
955
+ const MAC_APP_BINARY = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
956
+ const TIMEOUT_MS = 3e3;
957
+ /** The tailnet-only HTTPS port the setup command serves stim-server on. */
958
+ const SERVE_PORT = 7443;
959
+ function executable(path) {
960
+ try {
961
+ accessSync(path, constants.X_OK);
962
+ return true;
963
+ } catch {
964
+ return false;
965
+ }
966
+ }
967
+ function findTailscale(env) {
968
+ for (const dir of (env.PATH ?? "").split(delimiter)) if (dir && executable(join(dir, "tailscale"))) return join(dir, "tailscale");
969
+ return executable(MAC_APP_BINARY) ? MAC_APP_BINARY : null;
970
+ }
971
+ function parseTailscaleStatus(value) {
972
+ if (!isJsonObject(value) || typeof value.BackendState !== "string") return {
973
+ state: "unavailable",
974
+ reason: "`tailscale status --json` printed no BackendState"
975
+ };
976
+ if (value.BackendState !== "Running") return {
977
+ state: "not-running",
978
+ backendState: value.BackendState
979
+ };
980
+ const self = isJsonObject(value.Self) ? value.Self : {};
981
+ return {
982
+ state: "running",
983
+ ips: Array.isArray(value.TailscaleIPs) ? value.TailscaleIPs.filter((ip) => typeof ip === "string") : [],
984
+ dnsName: typeof self.DNSName === "string" && self.DNSName ? self.DNSName.replace(/\.$/, "") : null,
985
+ hostName: typeof self.HostName === "string" && self.HostName ? self.HostName : null
986
+ };
987
+ }
988
+ function tailscaleStatus(binary, env) {
989
+ if (!binary) return {
990
+ state: "unavailable",
991
+ reason: "the tailscale command was not found"
992
+ };
993
+ try {
994
+ const output = execFileSync(binary, ["status", "--json"], {
995
+ env,
996
+ timeout: TIMEOUT_MS,
997
+ encoding: "utf8",
998
+ stdio: [
999
+ "ignore",
1000
+ "pipe",
1001
+ "ignore"
1002
+ ]
1003
+ });
1004
+ return parseTailscaleStatus(JSON.parse(output));
1005
+ } catch (error) {
1006
+ return {
1007
+ state: "unavailable",
1008
+ reason: `\`tailscale status --json\` failed: ${error.message}`
1009
+ };
1010
+ }
1011
+ }
1012
+ function reaches(address, target, hosts) {
1013
+ if (typeof address !== "string") return null;
1014
+ let url;
1015
+ try {
1016
+ url = new URL(address.includes("://") ? address : `tcp://${address}`);
1017
+ } catch {
1018
+ return null;
1019
+ }
1020
+ return hosts.has(url.hostname) && url.port === String(target) ? url : null;
1021
+ }
1022
+ function portOf(hostPort) {
1023
+ return Number(hostPort.slice(hostPort.lastIndexOf(":") + 1));
1024
+ }
1025
+ /**
1026
+ * Reads `tailscale serve status --json`, an ipn.ServeConfig: `TCP` maps a port to `{ HTTPS }` or
1027
+ * `{ TCPForward }`, `Web` maps `<host>:<port>` to `{ Handlers: { <mount>: { Proxy } } }`,
1028
+ * `AllowFunnel` maps `<host>:<port>` to true, and `Foreground` holds the same shape per
1029
+ * foreground session. Any handler or TCP forward that reaches the server on a Funnel port exposes
1030
+ * it; only a `/` HTTP proxy on an HTTPS port is a route a client can use.
1031
+ */
1032
+ function parseServeStatus(value, target, ips) {
1033
+ if (!isJsonObject(value)) return {
1034
+ state: "unknown",
1035
+ reason: "it printed no serve config",
1036
+ port: SERVE_PORT
1037
+ };
1038
+ const hosts = /* @__PURE__ */ new Set([
1039
+ "127.0.0.1",
1040
+ "localhost",
1041
+ "[::1]",
1042
+ "0.0.0.0",
1043
+ "[::]",
1044
+ ...ips.map((ip) => ip.includes(":") ? `[${ip}]` : ip)
1045
+ ]);
1046
+ const configs = [value, ...isJsonObject(value.Foreground) ? Object.values(value.Foreground) : []].filter(isJsonObject);
1047
+ const used = /* @__PURE__ */ new Set();
1048
+ const funneled = /* @__PURE__ */ new Set();
1049
+ const reaching = /* @__PURE__ */ new Set();
1050
+ const routes = /* @__PURE__ */ new Set();
1051
+ for (const config of configs) {
1052
+ const tcp = isJsonObject(config.TCP) ? config.TCP : {};
1053
+ for (const [port, listener] of Object.entries(tcp)) {
1054
+ used.add(Number(port));
1055
+ if (isJsonObject(listener) && reaches(listener.TCPForward, target, hosts)) reaching.add(Number(port));
1056
+ }
1057
+ for (const [hostPort, allowed] of Object.entries(isJsonObject(config.AllowFunnel) ? config.AllowFunnel : {})) if (allowed === true) funneled.add(portOf(hostPort));
1058
+ for (const [hostPort, web] of Object.entries(isJsonObject(config.Web) ? config.Web : {})) {
1059
+ const port = portOf(hostPort);
1060
+ const listener = tcp[String(port)];
1061
+ const https = isJsonObject(listener) && listener.HTTPS === true;
1062
+ const handlers = isJsonObject(web) && isJsonObject(web.Handlers) ? web.Handlers : {};
1063
+ for (const [mount, handler] of Object.entries(handlers)) {
1064
+ const url = isJsonObject(handler) ? reaches(handler.Proxy, target, hosts) : null;
1065
+ if (!url) continue;
1066
+ reaching.add(port);
1067
+ if (https && mount === "/" && url.protocol === "http:" && url.pathname === "/") routes.add(port);
1068
+ }
1069
+ }
1070
+ }
1071
+ let free = SERVE_PORT;
1072
+ while (used.has(free) || funneled.has(free)) free++;
1073
+ const exposed = [...reaching].filter((port) => funneled.has(port)).toSorted((a, b) => a - b);
1074
+ if (exposed.length) return {
1075
+ state: "funneled",
1076
+ ports: exposed,
1077
+ port: free
1078
+ };
1079
+ const tailnet = [...routes].toSorted((a, b) => a - b);
1080
+ if (tailnet.length) return {
1081
+ state: "routed",
1082
+ port: tailnet.includes(SERVE_PORT) ? SERVE_PORT : tailnet[0]
1083
+ };
1084
+ return {
1085
+ state: "missing",
1086
+ port: free
1087
+ };
1088
+ }
1089
+ function serveRoute(binary, env, target, ips, timeoutMs = TIMEOUT_MS) {
1090
+ if (!binary) return Promise.resolve({
1091
+ state: "unknown",
1092
+ reason: "the tailscale command was not found",
1093
+ port: SERVE_PORT
1094
+ });
1095
+ return new Promise((resolve) => {
1096
+ execFile(binary, [
1097
+ "serve",
1098
+ "status",
1099
+ "--json"
1100
+ ], {
1101
+ env,
1102
+ timeout: timeoutMs,
1103
+ killSignal: "SIGKILL",
1104
+ encoding: "utf8"
1105
+ }, (error, stdout) => {
1106
+ let route;
1107
+ try {
1108
+ route = error ? {
1109
+ state: "unknown",
1110
+ reason: error.killed ? "it timed out" : error.message.split("\n")[0],
1111
+ port: SERVE_PORT
1112
+ } : parseServeStatus(stdout.trim() ? JSON.parse(stdout) : {}, target, ips);
1113
+ } catch {
1114
+ route = {
1115
+ state: "unknown",
1116
+ reason: "it printed output that is not JSON",
1117
+ port: SERVE_PORT
1118
+ };
1119
+ }
1120
+ resolve(route);
1121
+ });
1122
+ });
1123
+ }
1124
+ function tailnetEndpoint(dnsName, port) {
1125
+ return port === 443 ? `wss://${dnsName}` : `wss://${dnsName}:${port}`;
1126
+ }
1127
+ function serveCommand(port, target) {
1128
+ return `tailscale serve --bg --https=${port} http://127.0.0.1:${target}`;
1129
+ }
1130
+ /** Reads `tailscale whois --json`: the peer's node (`Node.StableID`, `Node.Name`) and user (`UserProfile.LoginName`). */
1131
+ function parseWhois(value) {
1132
+ if (!isJsonObject(value) || !isJsonObject(value.Node)) return null;
1133
+ const { StableID, Name } = value.Node;
1134
+ const login = isJsonObject(value.UserProfile) ? value.UserProfile.LoginName : void 0;
1135
+ if (typeof StableID !== "string" || !StableID) return null;
1136
+ return {
1137
+ kind: "tailnet",
1138
+ nodeId: StableID,
1139
+ nodeName: typeof Name === "string" ? Name.replace(/\.$/, "") : "",
1140
+ user: typeof login === "string" ? login : ""
1141
+ };
1142
+ }
1143
+ function whois(binary, env, ip) {
1144
+ if (!binary) return Promise.resolve(null);
1145
+ return new Promise((resolve) => {
1146
+ execFile(binary, [
1147
+ "whois",
1148
+ "--json",
1149
+ ip
1150
+ ], {
1151
+ env,
1152
+ timeout: TIMEOUT_MS,
1153
+ encoding: "utf8"
1154
+ }, (error, stdout) => {
1155
+ if (error) return resolve(null);
1156
+ try {
1157
+ resolve(parseWhois(JSON.parse(stdout)));
1158
+ } catch {
1159
+ resolve(null);
1160
+ }
1161
+ });
1162
+ });
1163
+ }
1164
+ //#endregion
1165
+ //#region src/server.ts
1166
+ function healthTailscale(tailscale) {
1167
+ if (tailscale.state === "running") return {
1168
+ state: "running",
1169
+ dnsName: tailscale.dnsName
1170
+ };
1171
+ if (tailscale.state === "not-running") return {
1172
+ state: "not-running",
1173
+ backendState: tailscale.backendState
1174
+ };
1175
+ return {
1176
+ state: "unavailable",
1177
+ reason: tailscale.reason
1178
+ };
1179
+ }
1180
+ /** Only a request made to this Mac's loopback name, so a DNS-rebound web page cannot read the health payload. */
1181
+ function localHealthRequest(request) {
1182
+ const host = request.headers.host?.replace(/:\d+$/, "");
1183
+ return request.method === "GET" && request.url === "/health" && (host === "127.0.0.1" || host === "localhost") && peerAddress(request) === null;
1184
+ }
1185
+ const CLOSE_UNAUTHORIZED = 4401;
1186
+ const CLOSE_BAD_REQUEST = 4400;
1187
+ const CLOSE_AUTH_TIMEOUT = 4408;
1188
+ const MAX_PAYLOAD = 65536;
1189
+ const MAX_SUBSCRIPTIONS = 32;
1190
+ const MAX_COMMANDS = 4;
1191
+ const LOG_LIMITS = {
1192
+ maxBufferedBytes: 4194304,
1193
+ maxPendingRecords: 2e4
1194
+ };
1195
+ const FRAME_BUFFER_BYTES = 1048576;
1196
+ const FRAME_RETRY_MS = 100;
1197
+ const STATUS_FEED = {
1198
+ args: [
1199
+ "status",
1200
+ "--watch",
1201
+ "--json"
1202
+ ],
1203
+ cwd: homedir(),
1204
+ keep: 1,
1205
+ label: "stim status --watch"
1206
+ };
1207
+ const HEALTH_ROUTE_TIMEOUT_MS = 1e3;
1208
+ const COMMAND_LIMITS = {
1209
+ timeoutMs: 6e4,
1210
+ maxOutputBytes: 33554432
1211
+ };
1212
+ const AUTH_REFUSALS = {
1213
+ "pairing-unknown": {
1214
+ code: "pairing-expired",
1215
+ message: "This pairing code is unknown or was already used. Pair again."
1216
+ },
1217
+ "pairing-expired": {
1218
+ code: "pairing-expired",
1219
+ message: "This pairing code expired. Pair again."
1220
+ },
1221
+ "device-unknown": {
1222
+ code: "unauthorized",
1223
+ message: "This Mac does not recognize this device token. Pair again."
1224
+ },
1225
+ "node-mismatch": {
1226
+ code: "unauthorized",
1227
+ message: "This device token was paired from a different tailnet node."
1228
+ }
1229
+ };
1230
+ function isLoopback(address) {
1231
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
1232
+ }
1233
+ /**
1234
+ * The tailnet address of the peer. `tailscale serve` proxies from loopback and names the peer in
1235
+ * X-Forwarded-For; a loopback connection without that header comes from this Mac.
1236
+ */
1237
+ function peerAddress(request) {
1238
+ const remote = request.socket.remoteAddress;
1239
+ if (!isLoopback(remote)) return remote ?? null;
1240
+ const forwarded = request.headers["x-forwarded-for"];
1241
+ return (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0]?.trim() || null;
1242
+ }
1243
+ var FailureLimiter = class {
1244
+ failures = /* @__PURE__ */ new Map();
1245
+ max;
1246
+ windowMs;
1247
+ constructor(max, windowMs) {
1248
+ this.max = max;
1249
+ this.windowMs = windowMs;
1250
+ }
1251
+ recent(key, now) {
1252
+ const kept = (this.failures.get(key) ?? []).filter((at) => now - at < this.windowMs);
1253
+ if (kept.length) this.failures.set(key, kept);
1254
+ else this.failures.delete(key);
1255
+ return kept;
1256
+ }
1257
+ blocked(key, now = Date.now()) {
1258
+ return this.recent(key, now).length >= this.max;
1259
+ }
1260
+ record(key, now = Date.now()) {
1261
+ this.failures.set(key, [...this.recent(key, now), now]);
1262
+ }
1263
+ };
1264
+ function send(socket, message) {
1265
+ if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
1266
+ }
1267
+ function requestId(value) {
1268
+ return typeof value === "number" || typeof value === "string" ? value : null;
1269
+ }
1270
+ async function startServer(options) {
1271
+ const limiter = new FailureLimiter(options.maxAuthFailures ?? 5, options.failureWindowMs ?? 6e4);
1272
+ const authTimeoutMs = options.authTimeoutMs ?? 5e3;
1273
+ const feeds = new FeedPool(options.stimCli, options.env);
1274
+ const frameLimits = {
1275
+ ...DEFAULT_FRAME_LIMITS,
1276
+ ...options.frameLimits
1277
+ };
1278
+ const frames = new FramePool(options.env, frameLimits);
1279
+ const running = /* @__PURE__ */ new Set();
1280
+ const logLimits = {
1281
+ ...LOG_LIMITS,
1282
+ ...options.logLimits
1283
+ };
1284
+ const commandLimits = {
1285
+ ...COMMAND_LIMITS,
1286
+ ...options.commandLimits
1287
+ };
1288
+ const sessions = /* @__PURE__ */ new Map();
1289
+ const wss = new WebSocketServer({
1290
+ noServer: true,
1291
+ maxPayload: MAX_PAYLOAD
1292
+ });
1293
+ mkdirSync(serverDir(), {
1294
+ recursive: true,
1295
+ mode: 448
1296
+ });
1297
+ let revocationCheck = null;
1298
+ const watcher = watch(serverDir(), () => {
1299
+ revocationCheck ??= setTimeout(() => {
1300
+ revocationCheck = null;
1301
+ const paired = new Set(readDevices().map((device) => device.id));
1302
+ for (const [socket, device] of sessions) if (!paired.has(device.id)) socket.close(CLOSE_UNAUTHORIZED, "device revoked");
1303
+ }, 50);
1304
+ });
1305
+ function connection(socket, peer) {
1306
+ const limitKey = peer ?? "local";
1307
+ const subscriptions = /* @__PURE__ */ new Map();
1308
+ const commands = /* @__PURE__ */ new Set();
1309
+ let nextSubscription = 1;
1310
+ let device = null;
1311
+ let queue = Promise.resolve();
1312
+ const refuse = (id, code, message, closeCode) => {
1313
+ if (!device) limiter.record(limitKey);
1314
+ send(socket, {
1315
+ id,
1316
+ error: {
1317
+ code,
1318
+ message
1319
+ }
1320
+ });
1321
+ socket.close(closeCode, code);
1322
+ };
1323
+ const timer = setTimeout(() => {
1324
+ limiter.record(limitKey);
1325
+ socket.close(CLOSE_AUTH_TIMEOUT, "authentication timeout");
1326
+ }, authTimeoutMs);
1327
+ async function identify() {
1328
+ if (peer === null) return { kind: "local" };
1329
+ return isIP(peer) ? whois(options.tailscale, options.env, peer) : null;
1330
+ }
1331
+ async function hello(id, params) {
1332
+ if (device) return send(socket, {
1333
+ id,
1334
+ error: {
1335
+ code: "already-authenticated",
1336
+ message: "hello was already accepted."
1337
+ }
1338
+ });
1339
+ const auth = isJsonObject(params) && isJsonObject(params.auth) ? params.auth : null;
1340
+ if (!isJsonObject(params) || !auth || !isJsonObject(params.client)) return refuse(id, "bad-request", "hello needs params.client and params.auth.", CLOSE_BAD_REQUEST);
1341
+ if (params.protocol !== 1) return refuse(id, "protocol-unsupported", `This server speaks protocol 1.`, CLOSE_BAD_REQUEST);
1342
+ clearTimeout(timer);
1343
+ const identity = await identify();
1344
+ if (socket.readyState !== socket.OPEN) return;
1345
+ if (!identity) return refuse(id, "identity-unavailable", `tailscale whois did not identify ${peer}.`, CLOSE_UNAUTHORIZED);
1346
+ let outcome;
1347
+ if (typeof auth.pairingToken === "string" && typeof auth.deviceName === "string" && auth.deviceName.trim()) outcome = spendPairingToken(auth.pairingToken, auth.deviceName.trim(), identity);
1348
+ else if (typeof auth.deviceToken === "string") outcome = authenticateDevice(auth.deviceToken, identity);
1349
+ else return refuse(id, "bad-request", "auth needs pairingToken and deviceName, or deviceToken.", CLOSE_BAD_REQUEST);
1350
+ if (!outcome.ok) {
1351
+ const { code, message } = AUTH_REFUSALS[outcome.reason];
1352
+ return refuse(id, code, message, CLOSE_UNAUTHORIZED);
1353
+ }
1354
+ device = outcome.device;
1355
+ sessions.set(socket, device);
1356
+ send(socket, {
1357
+ id,
1358
+ result: {
1359
+ protocol: 1,
1360
+ server: {
1361
+ name: options.name,
1362
+ version: options.serverVersion,
1363
+ stim: options.stimVersion
1364
+ },
1365
+ capabilities: device.capabilities,
1366
+ device: {
1367
+ id: device.id,
1368
+ name: device.name
1369
+ },
1370
+ ...outcome.deviceToken ? { deviceToken: outcome.deviceToken } : {}
1371
+ }
1372
+ });
1373
+ }
1374
+ function error(id, code, message) {
1375
+ send(socket, {
1376
+ id,
1377
+ error: {
1378
+ code,
1379
+ message
1380
+ }
1381
+ });
1382
+ }
1383
+ function workspaceDir(id, workspace, required) {
1384
+ if (workspace === void 0 && !required) return homedir();
1385
+ if (typeof workspace !== "string") {
1386
+ error(id, "bad-request", "params.workspace must be an environment path from a status payload.");
1387
+ return null;
1388
+ }
1389
+ let registered;
1390
+ try {
1391
+ registered = Object.hasOwn(loadConfig()?.projects ?? {}, workspace);
1392
+ } catch (cause) {
1393
+ error(id, "stim-failed", cause.message);
1394
+ return null;
1395
+ }
1396
+ if (!registered) {
1397
+ error(id, "unknown-workspace", `${workspace} is not a Stim workspace on this Mac.`);
1398
+ return null;
1399
+ }
1400
+ if (!existsSync(workspace)) {
1401
+ error(id, "unknown-workspace", `${workspace} is registered but no longer exists on this Mac.`);
1402
+ return null;
1403
+ }
1404
+ return workspace;
1405
+ }
1406
+ function openSubscription(id) {
1407
+ if (subscriptions.size >= MAX_SUBSCRIPTIONS) {
1408
+ error(id, "limit-exceeded", `A connection can hold ${MAX_SUBSCRIPTIONS} subscriptions.`);
1409
+ return null;
1410
+ }
1411
+ const subscription = `s${nextSubscription++}`;
1412
+ send(socket, {
1413
+ id,
1414
+ result: { subscription }
1415
+ });
1416
+ return subscription;
1417
+ }
1418
+ function subscribeStatus(id) {
1419
+ const subscription = openSubscription(id);
1420
+ if (!subscription) return;
1421
+ const unsubscribe = feeds.subscribe(STATUS_FEED, {
1422
+ item: (payload) => send(socket, {
1423
+ event: "status",
1424
+ subscription,
1425
+ payload
1426
+ }),
1427
+ failed: (message) => {
1428
+ subscriptions.delete(subscription);
1429
+ send(socket, {
1430
+ event: "error",
1431
+ subscription,
1432
+ error: {
1433
+ code: "status-failed",
1434
+ message
1435
+ }
1436
+ });
1437
+ }
1438
+ });
1439
+ subscriptions.set(subscription, unsubscribe);
1440
+ }
1441
+ function subscribeLogs(id, params) {
1442
+ const parsed = parseLogFilter(params);
1443
+ if ("error" in parsed) return error(id, "bad-request", parsed.error);
1444
+ const cwd = workspaceDir(id, parsed.filter.workspace, true);
1445
+ if (!cwd) return;
1446
+ const subscription = openSubscription(id);
1447
+ if (!subscription) return;
1448
+ const end = () => {
1449
+ subscriptions.get(subscription)?.();
1450
+ subscriptions.delete(subscription);
1451
+ };
1452
+ const batcher = new LogBatcher({
1453
+ send: (records) => send(socket, {
1454
+ event: "logs",
1455
+ subscription,
1456
+ records
1457
+ }),
1458
+ bufferedBytes: () => socket.bufferedAmount,
1459
+ overflow: () => {
1460
+ end();
1461
+ send(socket, {
1462
+ event: "error",
1463
+ subscription,
1464
+ error: {
1465
+ code: "slow-client",
1466
+ message: "This client fell behind the log stream. Subscribe again."
1467
+ }
1468
+ });
1469
+ }
1470
+ }, logLimits);
1471
+ const unsubscribe = feeds.subscribe({
1472
+ args: logArgs(parsed.filter, true),
1473
+ cwd,
1474
+ keep: parsed.filter.tail,
1475
+ label: "stim logs --follow"
1476
+ }, {
1477
+ item: (record) => batcher.push(record),
1478
+ failed: (message) => {
1479
+ batcher.flush(true);
1480
+ batcher.stop();
1481
+ subscriptions.delete(subscription);
1482
+ send(socket, {
1483
+ event: "error",
1484
+ subscription,
1485
+ error: {
1486
+ code: "logs-failed",
1487
+ message
1488
+ }
1489
+ });
1490
+ }
1491
+ });
1492
+ subscriptions.set(subscription, () => {
1493
+ batcher.stop();
1494
+ unsubscribe();
1495
+ });
1496
+ }
1497
+ function subscribeFrames(id, params) {
1498
+ const { workspace, platform, slot } = isJsonObject(params) ? params : {};
1499
+ if (typeof workspace !== "string" || platform !== "ios" && platform !== "android") return error(id, "bad-request", "frames.subscribe needs params.workspace and params.platform (ios or android).");
1500
+ if (slot !== void 0 && (typeof slot !== "string" || slot === "")) return error(id, "bad-request", "slot must be a slot name.");
1501
+ if (!workspaceDir(id, workspace, true)) return;
1502
+ const subscription = openSubscription(id);
1503
+ if (!subscription) return;
1504
+ const frameTarget = {
1505
+ workspace,
1506
+ platform,
1507
+ ...slot ? { slot } : {}
1508
+ };
1509
+ let attached = null;
1510
+ let detach = null;
1511
+ let pending = null;
1512
+ let retry = null;
1513
+ let ended = false;
1514
+ const flush = () => {
1515
+ retry = null;
1516
+ if (!pending || ended) return;
1517
+ if (socket.bufferedAmount > FRAME_BUFFER_BYTES) {
1518
+ retry = setTimeout(flush, FRAME_RETRY_MS);
1519
+ return;
1520
+ }
1521
+ const frame = pending;
1522
+ pending = null;
1523
+ send(socket, {
1524
+ event: "frame",
1525
+ subscription,
1526
+ platform,
1527
+ slot: slot ?? "default",
1528
+ mime: "image/jpeg",
1529
+ ...frame
1530
+ });
1531
+ };
1532
+ const cleanup = () => {
1533
+ ended = true;
1534
+ if (retry) clearTimeout(retry);
1535
+ detach?.();
1536
+ detach = null;
1537
+ unsubscribeStatus?.();
1538
+ };
1539
+ const end = (message) => {
1540
+ if (ended) return;
1541
+ cleanup();
1542
+ subscriptions.delete(subscription);
1543
+ send(socket, {
1544
+ event: "error",
1545
+ subscription,
1546
+ error: {
1547
+ code: "frames-failed",
1548
+ message
1549
+ }
1550
+ });
1551
+ };
1552
+ const listener = {
1553
+ frame: (frame) => {
1554
+ pending = frame;
1555
+ if (!retry) flush();
1556
+ },
1557
+ delayed: (delayed) => {
1558
+ if (!ended) send(socket, {
1559
+ event: "frame-delayed",
1560
+ subscription,
1561
+ delayed
1562
+ });
1563
+ },
1564
+ failed: end
1565
+ };
1566
+ let unsubscribeStatus = null;
1567
+ unsubscribeStatus = feeds.subscribe(STATUS_FEED, {
1568
+ item: (payload) => {
1569
+ if (ended) return;
1570
+ const resolved = ownedDevice(payload, frameTarget, attached);
1571
+ if (typeof resolved === "string") return queueMicrotask(() => end(resolved));
1572
+ if (deviceKey(resolved) === attached) return;
1573
+ detach?.();
1574
+ attached = deviceKey(resolved);
1575
+ detach = frames.subscribe(resolved, listener);
1576
+ },
1577
+ failed: (message) => queueMicrotask(() => end(message))
1578
+ });
1579
+ subscriptions.set(subscription, cleanup);
1580
+ }
1581
+ function command(id, args, cwd, result) {
1582
+ if (commands.size >= MAX_COMMANDS) return error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
1583
+ const run = runStim(options.stimCli, options.env, args, cwd, commandLimits);
1584
+ commands.add(run.cancel);
1585
+ running.add(run.cancel);
1586
+ (async () => {
1587
+ const outcome = await run.outcome;
1588
+ commands.delete(run.cancel);
1589
+ running.delete(run.cancel);
1590
+ if (!outcome.ok) return error(id, "stim-failed", outcome.message);
1591
+ let value;
1592
+ try {
1593
+ value = result(outcome.stdout);
1594
+ } catch {
1595
+ return error(id, "stim-failed", `stim ${args[0]} printed output that is not JSON.`);
1596
+ }
1597
+ send(socket, {
1598
+ id,
1599
+ result: value
1600
+ });
1601
+ })();
1602
+ }
1603
+ function queryLogs(id, params) {
1604
+ const parsed = parseLogFilter(params);
1605
+ if ("error" in parsed) return error(id, "bad-request", parsed.error);
1606
+ const cwd = workspaceDir(id, parsed.filter.workspace, true);
1607
+ if (!cwd) return;
1608
+ command(id, logArgs(parsed.filter, false), cwd, (stdout) => ({ records: stdout.split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line)) }));
1609
+ }
1610
+ function workspaceCommand(id, method, params) {
1611
+ if (params !== void 0 && !isJsonObject(params)) return error(id, "bad-request", "params must be an object.");
1612
+ const cwd = workspaceDir(id, params?.workspace, false);
1613
+ if (!cwd) return;
1614
+ command(id, [method === "stats.get" ? "stats" : "settings", "--json"], cwd, (stdout) => {
1615
+ const value = JSON.parse(stdout);
1616
+ if (!isJsonObject(value)) throw new Error("not an object");
1617
+ return value;
1618
+ });
1619
+ }
1620
+ async function handle(raw) {
1621
+ if (socket.readyState !== socket.OPEN) return;
1622
+ let message;
1623
+ try {
1624
+ message = JSON.parse(raw);
1625
+ } catch {
1626
+ message = null;
1627
+ }
1628
+ const id = isJsonObject(message) ? requestId(message.id) : null;
1629
+ if (!isJsonObject(message) || id === null || typeof message.method !== "string") {
1630
+ if (device) return send(socket, {
1631
+ id,
1632
+ error: {
1633
+ code: "bad-request",
1634
+ message: "Expected {id, method, params}."
1635
+ }
1636
+ });
1637
+ return refuse(id, "bad-request", "Expected {id, method, params}.", CLOSE_BAD_REQUEST);
1638
+ }
1639
+ if (message.method === "hello") return hello(id, message.params);
1640
+ if (!device) return refuse(id, "unauthorized", "Send hello first.", CLOSE_UNAUTHORIZED);
1641
+ if (message.method === "status.subscribe") return subscribeStatus(id);
1642
+ if (message.method === "logs.subscribe") return subscribeLogs(id, message.params);
1643
+ if (message.method === "logs.query") return queryLogs(id, message.params);
1644
+ if (message.method === "frames.subscribe") return subscribeFrames(id, message.params);
1645
+ if (message.method === "stats.get" || message.method === "settings.get") return workspaceCommand(id, message.method, message.params);
1646
+ if (message.method === "unsubscribe") {
1647
+ const name = isJsonObject(message.params) ? message.params.subscription : void 0;
1648
+ const unsubscribe = typeof name === "string" ? subscriptions.get(name) : void 0;
1649
+ if (!unsubscribe) return send(socket, {
1650
+ id,
1651
+ error: {
1652
+ code: "unknown-subscription",
1653
+ message: `No subscription ${String(name)}.`
1654
+ }
1655
+ });
1656
+ subscriptions.delete(name);
1657
+ unsubscribe();
1658
+ return send(socket, {
1659
+ id,
1660
+ result: {}
1661
+ });
1662
+ }
1663
+ send(socket, {
1664
+ id,
1665
+ error: {
1666
+ code: "unknown-method",
1667
+ message: `Unknown method ${message.method}.`
1668
+ }
1669
+ });
1670
+ }
1671
+ socket.on("message", (data) => {
1672
+ const raw = data.toString();
1673
+ queue = queue.then(() => handle(raw)).catch(() => socket.close(1011, "internal error"));
1674
+ });
1675
+ socket.on("close", () => {
1676
+ clearTimeout(timer);
1677
+ sessions.delete(socket);
1678
+ for (const unsubscribe of subscriptions.values()) unsubscribe();
1679
+ subscriptions.clear();
1680
+ for (const cancel of commands) {
1681
+ running.delete(cancel);
1682
+ cancel();
1683
+ }
1684
+ commands.clear();
1685
+ });
1686
+ }
1687
+ function upgrade(request, socket, head) {
1688
+ const peer = peerAddress(request);
1689
+ if (limiter.blocked(peer ?? "local")) {
1690
+ socket.end("HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
1691
+ return;
1692
+ }
1693
+ wss.handleUpgrade(request, socket, head, (ws) => connection(ws, peer));
1694
+ }
1695
+ const health = {
1696
+ server: "stim-server",
1697
+ name: options.name,
1698
+ version: options.serverVersion,
1699
+ stim: options.stimVersion,
1700
+ protocol: 1,
1701
+ stimHome: configDir(),
1702
+ tailscale: healthTailscale(options.tailscaleState)
1703
+ };
1704
+ const answerHealth = async (response) => {
1705
+ const tailscale = options.tailscaleState;
1706
+ const route = tailscale.state === "running" && tailscale.dnsName ? await serveRoute(options.tailscale, options.env, addresses[0].port, tailscale.ips, HEALTH_ROUTE_TIMEOUT_MS) : void 0;
1707
+ response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({
1708
+ ...health,
1709
+ route
1710
+ }));
1711
+ };
1712
+ const servers = [];
1713
+ const addresses = [];
1714
+ const close = async () => {
1715
+ watcher.close();
1716
+ if (revocationCheck) clearTimeout(revocationCheck);
1717
+ for (const client of wss.clients) client.terminate();
1718
+ await Promise.all([
1719
+ frames.close(),
1720
+ feeds.close(),
1721
+ ...[...running].map((cancel) => cancel())
1722
+ ]);
1723
+ wss.close();
1724
+ await Promise.all(servers.map((server) => new Promise((resolve) => server.close(resolve))));
1725
+ };
1726
+ try {
1727
+ for (const host of options.hosts) {
1728
+ const server = createServer((request, response) => {
1729
+ if (localHealthRequest(request)) {
1730
+ answerHealth(response);
1731
+ return;
1732
+ }
1733
+ response.writeHead(426, { "content-type": "text/plain" }).end("stim-server speaks WebSocket only.\n");
1734
+ });
1735
+ server.on("upgrade", upgrade);
1736
+ servers.push(server);
1737
+ await new Promise((resolve, reject) => {
1738
+ server.once("error", reject);
1739
+ server.listen(options.port, host, () => {
1740
+ server.off("error", reject);
1741
+ resolve();
1742
+ });
1743
+ });
1744
+ addresses.push({
1745
+ host,
1746
+ port: server.address().port
1747
+ });
1748
+ }
1749
+ } catch (error) {
1750
+ await close();
1751
+ throw error;
1752
+ }
1753
+ return {
1754
+ addresses,
1755
+ close
1756
+ };
1757
+ }
1758
+ //#endregion
1759
+ //#region bin/stim-server.ts
1760
+ const DEFAULT_PORT = 7787;
1761
+ const USAGE = `Usage:
1762
+ stim-server [--port <n>] serve paired clients (default port ${DEFAULT_PORT})
1763
+ stim-server pair [--port <n>] [--json]
1764
+ print a single-use pairing payload for the QR code
1765
+ stim-server devices [list] [--json]
1766
+ list paired devices
1767
+ stim-server devices revoke <id> revoke a paired device`;
1768
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
1769
+ function fail(message) {
1770
+ console.error(`stim-server: ${message}`);
1771
+ process.exit(1);
1772
+ }
1773
+ function tailscaleNote(tailscale) {
1774
+ const remedy = `Remote clients cannot connect until Tailscale runs; then restart stim-server, which prints the \`tailscale serve\` command to run once.`;
1775
+ if (tailscale.state === "not-running") return `Tailscale is not running (${tailscale.backendState}). ${remedy}`;
1776
+ if (tailscale.state === "unavailable") return `Tailscale is unavailable: ${tailscale.reason}. ${remedy}`;
1777
+ return null;
1778
+ }
1779
+ function routeNote(route, port) {
1780
+ if (route.state === "missing") return `No \`tailscale serve\` route reaches port ${port}, so phones cannot connect yet. Run this once to serve it on a tailnet-only port: \`${serveCommand(route.port, port)}\``;
1781
+ if (route.state === "unknown") return `Could not read \`tailscale serve status --json\` (${route.reason}); assuming stim-server is served on port ${route.port}.`;
1782
+ if (route.state === "funneled") return `Tailscale Funnel is on for port ${route.ports.join(", ")}, which proxies to stim-server, so the server is reachable from the public internet. Remove that handler (see \`tailscale serve status\`), then serve stim-server on a tailnet-only port: \`${serveCommand(route.port, port)}\``;
1783
+ return null;
1784
+ }
1785
+ function macName(tailscale) {
1786
+ return tailscale.state === "running" && tailscale.hostName || hostname();
1787
+ }
1788
+ async function serve(port) {
1789
+ const login = loginShellEnvironment();
1790
+ if (!login) console.error("stim-server: could not read the login shell environment; using this process environment.");
1791
+ const env = { ...login ?? process.env };
1792
+ if (!process.env.STIM_HOME && env.STIM_HOME) process.env.STIM_HOME = env.STIM_HOME;
1793
+ if (process.env.STIM_HOME) env.STIM_HOME = process.env.STIM_HOME;
1794
+ const tailscaleBinary = findTailscale(env);
1795
+ const tailscale = tailscaleStatus(tailscaleBinary, env);
1796
+ const stim = bundledStim();
1797
+ const hosts = ["127.0.0.1", ...tailscale.state === "running" ? tailscale.ips : []];
1798
+ let server;
1799
+ try {
1800
+ server = await startServer({
1801
+ name: macName(tailscale),
1802
+ hosts,
1803
+ port,
1804
+ stimCli: stim.cli,
1805
+ stimVersion: stim.version,
1806
+ serverVersion: pkg.version,
1807
+ env,
1808
+ tailscale: tailscaleBinary,
1809
+ tailscaleState: tailscale
1810
+ });
1811
+ } catch (error) {
1812
+ if (error.code === "EADDRINUSE") fail(`port ${port} is in use; another stim-server may already be running. Pass --port to use another port.`);
1813
+ throw error;
1814
+ }
1815
+ console.log(`stim-server ${pkg.version} (stim ${stim.version}, protocol 1)`);
1816
+ for (const { host, port: bound } of server.addresses) console.log(`listening on ws://${host.includes(":") ? `[${host}]` : host}:${bound}`);
1817
+ let note = tailscaleNote(tailscale);
1818
+ if (tailscale.state === "running" && tailscale.dnsName) {
1819
+ const route = await serveRoute(tailscaleBinary, env, port, tailscale.ips);
1820
+ if (route.state !== "funneled") console.log(`tailnet endpoint: ${tailnetEndpoint(tailscale.dnsName, route.port)}`);
1821
+ note = routeNote(route, port);
1822
+ if (route.state === "funneled") note = `${note} Pairing is refused until then.`;
1823
+ }
1824
+ if (note) console.error(note);
1825
+ const shutdown = () => {
1826
+ server.close().then(() => process.exit(0));
1827
+ };
1828
+ process.on("SIGINT", shutdown);
1829
+ process.on("SIGTERM", shutdown);
1830
+ process.on("SIGHUP", shutdown);
1831
+ }
1832
+ async function pair(port, json) {
1833
+ const binary = findTailscale(process.env);
1834
+ const tailscale = tailscaleStatus(binary, process.env);
1835
+ let endpoint = `ws://127.0.0.1:${port}`;
1836
+ let note = tailscaleNote(tailscale);
1837
+ if (note) note = `${note} The endpoint above only works on this Mac.`;
1838
+ if (tailscale.state === "running" && tailscale.dnsName) {
1839
+ const route = await serveRoute(binary, process.env, port, tailscale.ips);
1840
+ if (route.state === "funneled") fail(`refusing to pair. ${routeNote(route, port)}`);
1841
+ endpoint = tailnetEndpoint(tailscale.dnsName, route.port);
1842
+ note = routeNote(route, port);
1843
+ }
1844
+ const { token, expiresAt } = createPairingToken();
1845
+ const payload = {
1846
+ v: 1,
1847
+ name: macName(tailscale),
1848
+ endpoint,
1849
+ pairingToken: token
1850
+ };
1851
+ if (json) return void console.log(JSON.stringify({
1852
+ qr: payload,
1853
+ expiresAt
1854
+ }));
1855
+ console.log(JSON.stringify(payload));
1856
+ console.error(`The pairing token is single use and expires at ${expiresAt}.`);
1857
+ if (note) console.error(note);
1858
+ }
1859
+ function describe(device) {
1860
+ const from = device.identity.kind === "local" ? "this Mac" : `${device.identity.nodeName || device.identity.nodeId}${device.identity.user ? ` (${device.identity.user})` : ""}`;
1861
+ return `${device.id} ${device.name} from ${from} paired ${device.pairedAt} last seen ${device.lastSeenAt ?? "never"}`;
1862
+ }
1863
+ async function main() {
1864
+ const { values, positionals } = parseArgs({
1865
+ allowPositionals: true,
1866
+ options: {
1867
+ port: { type: "string" },
1868
+ json: { type: "boolean" },
1869
+ help: {
1870
+ type: "boolean",
1871
+ short: "h"
1872
+ },
1873
+ version: {
1874
+ type: "boolean",
1875
+ short: "V"
1876
+ }
1877
+ }
1878
+ });
1879
+ if (values.help) return void console.log(USAGE);
1880
+ if (values.version) return void console.log(pkg.version);
1881
+ const port = values.port === void 0 ? DEFAULT_PORT : Number(values.port);
1882
+ if (!Number.isInteger(port) || port < 0 || port > 65535) fail(`--port must be a port number, got ${values.port}.`);
1883
+ const [command, sub, arg, ...rest] = positionals;
1884
+ if (command === void 0) return serve(port);
1885
+ if (command === "pair" && sub === void 0) return pair(port, values.json === true);
1886
+ if (command === "devices" && (sub === void 0 || sub === "list") && arg === void 0) {
1887
+ const devices = readDevices();
1888
+ if (values.json) {
1889
+ const listed = devices.map(({ id, name, identity, pairedAt, lastSeenAt, capabilities }) => ({
1890
+ id,
1891
+ name,
1892
+ identity,
1893
+ pairedAt,
1894
+ lastSeenAt,
1895
+ capabilities
1896
+ }));
1897
+ console.log(JSON.stringify({ devices: listed }));
1898
+ return;
1899
+ }
1900
+ if (!devices.length) console.log("No paired devices.");
1901
+ for (const device of devices) console.log(describe(device));
1902
+ return;
1903
+ }
1904
+ if (command === "devices" && sub === "revoke" && arg !== void 0 && rest.length === 0) {
1905
+ if (!revokeDevice(arg)) fail(`no paired device ${arg}. Run \`stim-server devices\` to list them.`);
1906
+ console.log(`Revoked ${arg}.`);
1907
+ return;
1908
+ }
1909
+ fail(`unknown command.\n${USAGE}`);
1910
+ }
1911
+ await main();
1912
+ //#endregion
1913
+ export {};