@limrun/api 0.49.2 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/exec-client.d.mts +25 -1
  3. package/exec-client.d.mts.map +1 -1
  4. package/exec-client.d.ts +25 -1
  5. package/exec-client.d.ts.map +1 -1
  6. package/exec-client.js +362 -197
  7. package/exec-client.js.map +1 -1
  8. package/exec-client.mjs +361 -197
  9. package/exec-client.mjs.map +1 -1
  10. package/folder-sync-ignore.d.mts +15 -12
  11. package/folder-sync-ignore.d.mts.map +1 -1
  12. package/folder-sync-ignore.d.ts +15 -12
  13. package/folder-sync-ignore.d.ts.map +1 -1
  14. package/folder-sync-ignore.js +16 -19
  15. package/folder-sync-ignore.js.map +1 -1
  16. package/folder-sync-ignore.mjs +16 -19
  17. package/folder-sync-ignore.mjs.map +1 -1
  18. package/index.d.mts +1 -1
  19. package/index.d.mts.map +1 -1
  20. package/index.d.ts +1 -1
  21. package/index.d.ts.map +1 -1
  22. package/index.js.map +1 -1
  23. package/index.mjs.map +1 -1
  24. package/instance-client.d.mts +11 -0
  25. package/instance-client.d.mts.map +1 -1
  26. package/instance-client.d.ts +11 -0
  27. package/instance-client.d.ts.map +1 -1
  28. package/instance-client.js +10 -1
  29. package/instance-client.js.map +1 -1
  30. package/instance-client.mjs +7 -0
  31. package/instance-client.mjs.map +1 -1
  32. package/ios-client.d.mts +2 -33
  33. package/ios-client.d.mts.map +1 -1
  34. package/ios-client.d.ts +2 -33
  35. package/ios-client.d.ts.map +1 -1
  36. package/ios-client.js +8 -99
  37. package/ios-client.js.map +1 -1
  38. package/ios-client.mjs +4 -95
  39. package/ios-client.mjs.map +1 -1
  40. package/log-stream.d.mts +35 -0
  41. package/log-stream.d.mts.map +1 -0
  42. package/log-stream.d.ts +35 -0
  43. package/log-stream.d.ts.map +1 -0
  44. package/log-stream.js +103 -0
  45. package/log-stream.js.map +1 -0
  46. package/log-stream.mjs +99 -0
  47. package/log-stream.mjs.map +1 -0
  48. package/logcat-process.d.mts +40 -0
  49. package/logcat-process.d.mts.map +1 -0
  50. package/logcat-process.d.ts +40 -0
  51. package/logcat-process.d.ts.map +1 -0
  52. package/logcat-process.js +122 -0
  53. package/logcat-process.js.map +1 -0
  54. package/logcat-process.mjs +118 -0
  55. package/logcat-process.mjs.map +1 -0
  56. package/mise-tools.d.mts +4 -0
  57. package/mise-tools.d.mts.map +1 -0
  58. package/mise-tools.d.ts +4 -0
  59. package/mise-tools.d.ts.map +1 -0
  60. package/mise-tools.js +63 -0
  61. package/mise-tools.js.map +1 -0
  62. package/mise-tools.mjs +58 -0
  63. package/mise-tools.mjs.map +1 -0
  64. package/package.json +32 -1
  65. package/resources/android-instances.d.mts +4 -0
  66. package/resources/android-instances.d.mts.map +1 -1
  67. package/resources/android-instances.d.ts +4 -0
  68. package/resources/android-instances.d.ts.map +1 -1
  69. package/resources/daemon-client-shared.d.mts +11 -0
  70. package/resources/daemon-client-shared.d.mts.map +1 -1
  71. package/resources/daemon-client-shared.d.ts +11 -0
  72. package/resources/daemon-client-shared.d.ts.map +1 -1
  73. package/resources/daemon-client-shared.js +23 -0
  74. package/resources/daemon-client-shared.js.map +1 -1
  75. package/resources/daemon-client-shared.mjs +22 -0
  76. package/resources/daemon-client-shared.mjs.map +1 -1
  77. package/resources/gradle-instances-helpers.d.mts +12 -2
  78. package/resources/gradle-instances-helpers.d.mts.map +1 -1
  79. package/resources/gradle-instances-helpers.d.ts +12 -2
  80. package/resources/gradle-instances-helpers.d.ts.map +1 -1
  81. package/resources/gradle-instances-helpers.js +6 -0
  82. package/resources/gradle-instances-helpers.js.map +1 -1
  83. package/resources/gradle-instances-helpers.mjs +8 -2
  84. package/resources/gradle-instances-helpers.mjs.map +1 -1
  85. package/resources/xcode-instances-helpers.d.mts +6 -15
  86. package/resources/xcode-instances-helpers.d.mts.map +1 -1
  87. package/resources/xcode-instances-helpers.d.ts +6 -15
  88. package/resources/xcode-instances-helpers.d.ts.map +1 -1
  89. package/resources/xcode-instances-helpers.js +3 -18
  90. package/resources/xcode-instances-helpers.js.map +1 -1
  91. package/resources/xcode-instances-helpers.mjs +5 -20
  92. package/resources/xcode-instances-helpers.mjs.map +1 -1
  93. package/src/exec-client.ts +404 -198
  94. package/src/folder-sync-ignore.ts +21 -21
  95. package/src/index.ts +1 -0
  96. package/src/instance-client.ts +16 -0
  97. package/src/ios-client.ts +4 -129
  98. package/src/log-stream.ts +132 -0
  99. package/src/logcat-process.ts +151 -0
  100. package/src/mise-tools.ts +55 -0
  101. package/src/resources/android-instances.ts +5 -0
  102. package/src/resources/daemon-client-shared.ts +33 -0
  103. package/src/resources/gradle-instances-helpers.ts +23 -0
  104. package/src/resources/xcode-instances-helpers.ts +13 -33
  105. package/src/version.ts +1 -1
  106. package/version.d.mts +1 -1
  107. package/version.d.ts +1 -1
  108. package/version.js +1 -1
  109. package/version.mjs +1 -1
@@ -11,12 +11,15 @@ export type IgnoreFn = (relativePath: string) => boolean;
11
11
  export type IgnoreLogger = (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
12
12
 
13
13
  export type IgnoreFnOptions = {
14
- /** User-supplied extra excludes (the CLI's --ignore). Consulted after every built-in layer. */
14
+ /**
15
+ * User-supplied extra excludes (the CLI's --ignore). Matches override
16
+ * built-in force-includes, but a matching --include still wins.
17
+ */
15
18
  additional?: IgnoreFn;
16
19
  /**
17
20
  * User-supplied force-includes (the CLI's --include): matches sync even when
18
- * covered by a built-in exclude, gitignored, or covered by the default Xcode
19
- * excludes. The basis cache remains excluded to prevent sync loops. Note:
21
+ * covered by --ignore, a built-in exclude, gitignored, or covered by the
22
+ * default Xcode excludes. The basis cache remains excluded to prevent sync loops. Note:
20
23
  * the walk prunes excluded directories, so to rescue files under a
21
24
  * wholly-excluded parent the predicate must also match the parent directory
22
25
  * paths (probed with a trailing slash), e.g. `^ios/` reaches
@@ -105,22 +108,22 @@ const XCODE_DEFAULT_EXCLUDE_PREFIXES = [
105
108
  * Builds the layered sync ignore predicate. First decisive answer wins:
106
109
  *
107
110
  * 1. The basis cache: excluded, never overridable.
108
- * 2. User include (--include): explicit intent beats every other exclusion.
109
- * 3. `.git` and `.DS_Store`.
110
- * 4. Default Xcode/dependency excludes (when xcodeDefaults is set).
111
- * 5. Built-in force-include: `*.xcconfig` and `.env` files (gitignored
111
+ * 2. User include (--include): explicit intent beats --ignore and every
112
+ * overridable exclusion.
113
+ * 3. User ignore (--ignore): explicit exclusion beats built-in force-includes.
114
+ * 4. `.git` and `.DS_Store`.
115
+ * 5. Default Xcode/dependency excludes (when xcodeDefaults is set).
116
+ * 6. Built-in force-include: `*.xcconfig` and `.env` files (gitignored
112
117
  * xcconfigs and env files are still required to reproduce the build
113
118
  * remotely; Expo app configs routinely read `.env`). Gitignored projects
114
119
  * are NOT force-included: limbuild regenerates them from project.yml, and
115
120
  * exact-version holdouts force-sync theirs with `--include`.
116
- * 6. `.gitignore` chain: the root file, plus nested ones with git semantics
121
+ * 7. `.gitignore` chain: the root file, plus nested ones with git semantics
117
122
  * when xcodeDefaults is set (rules bind relative to their containing
118
- * directory, deeper files override shallower ones). Only a decisive
119
- * *exclude* short-circuits; a negation re-include defers to layer 7.
120
- * Directory pruning in the walk means only layers 2 and 5 can reach a
123
+ * directory, deeper files override shallower ones).
124
+ * Directory pruning in the walk means only layers 2 and 6 can reach a
121
125
  * file whose parent directory is gitignore-excluded, and only when the
122
126
  * predicate matches the pruned parent directory paths too.
123
- * 7. User ignore (--ignore).
124
127
  */
125
128
  export async function createIgnoreFn(rootDir: string, options: IgnoreFnOptions): Promise<IgnoreFn> {
126
129
  const rootResolved = path.resolve(rootDir);
@@ -210,7 +213,9 @@ export async function createIgnoreFn(rootDir: string, options: IgnoreFnOptions):
210
213
  }
211
214
  // 2. User include.
212
215
  if (options.include?.(normalized)) return false;
213
- // 3. Built-in excludes.
216
+ // 3. User ignore.
217
+ if (options.additional?.(normalized)) return true;
218
+ // 4. Built-in excludes.
214
219
  if (
215
220
  withoutTrailingSlash === '.git' ||
216
221
  withoutTrailingSlash.startsWith('.git/') ||
@@ -221,14 +226,14 @@ export async function createIgnoreFn(rootDir: string, options: IgnoreFnOptions):
221
226
  ) {
222
227
  return true;
223
228
  }
224
- // 4. Default Xcode/dependency excludes.
229
+ // 5. Default Xcode/dependency excludes.
225
230
  if (options.xcodeDefaults) {
226
231
  for (const prefix of XCODE_DEFAULT_EXCLUDE_PREFIXES) {
227
232
  if (normalized.startsWith(prefix)) return true;
228
233
  }
229
234
  if (normalized.includes('/xcuserdata/') || normalized.includes('.dSYM/')) return true;
230
235
  }
231
- // 5. Built-in force-include: gitignored xcconfigs and .env files are
236
+ // 6. Built-in force-include: gitignored xcconfigs and .env files are
232
237
  // still required to reproduce the build remotely (Expo app configs read
233
238
  // .env / .env.local / .env.production at prebuild time). Gitignored
234
239
  // .xcodeproj bundles are NOT force-included: limbuild regenerates them
@@ -237,10 +242,7 @@ export async function createIgnoreFn(rootDir: string, options: IgnoreFnOptions):
237
242
  if (withoutTrailingSlash.endsWith('.xcconfig')) return false;
238
243
  const basename = withoutTrailingSlash.slice(withoutTrailingSlash.lastIndexOf('/') + 1);
239
244
  if (basename.endsWith('.env') || basename.startsWith('.env.')) return false;
240
- // 6. The .gitignore chain. Only a decisive *exclude* short-circuits; a
241
- // negation re-include (decision.ignored === false) still falls through to
242
- // the user --ignore layer, matching the pre-restructure precedence where
243
- // gitignore never overrode --ignore for a re-included path.
245
+ // 7. The .gitignore chain.
244
246
  const decision = gitignoreDecision(normalized);
245
247
  if (decision?.ignored) {
246
248
  if (
@@ -260,8 +262,6 @@ export async function createIgnoreFn(rootDir: string, options: IgnoreFnOptions):
260
262
  }
261
263
  return true;
262
264
  }
263
- // 7. User ignore.
264
- if (options.additional?.(normalized)) return true;
265
265
  return false;
266
266
  };
267
267
  }
package/src/index.ts CHANGED
@@ -133,6 +133,7 @@ export {
133
133
  type GradleClient,
134
134
  type GradleSyncOptions,
135
135
  type GradleBuildOptions,
136
+ type GradleRunOptions,
136
137
  type GradleBuildLog,
137
138
  } from './resources/gradle-instances-helpers';
138
139
  export { type AssetUploadOptions } from './resources/daemon-client-shared';
@@ -26,8 +26,11 @@ import { deriveDestinationTunnelURL } from './internal/destination-tunnel-url';
26
26
  import { persistFields, type PersistOption } from './internal/persist-option';
27
27
  import { streamSessionEntries } from './internal/session-stream';
28
28
  import type { SessionLogLine, SessionEvent } from './resources/session-artifacts';
29
+ import { LogcatProcess } from './logcat-process';
29
30
 
30
31
  export { type PersistOption } from './internal/persist-option';
32
+ export { LogStream, type LogStreamEvents } from './log-stream';
33
+ export { LogcatProcess, type LogcatProcessEvents } from './logcat-process';
31
34
 
32
35
  const ANDROID_RECORDING_PATH = '/data/local/tmp/recordings/video_recording.mp4';
33
36
  const ANDROID_SIGNALING_PATH = '/ws';
@@ -278,6 +281,14 @@ export type InstanceClient = {
278
281
  * non-zero `exitCode` is returned in the result, not thrown.
279
282
  */
280
283
  adbShell: (command: string, args?: string[], options?: AdbShellOptions) => Promise<AdbShellResult>;
284
+ /**
285
+ * Run logcat with the given arguments and stream its output over a
286
+ * per-run SSE stream. Arguments are forwarded to logcat verbatim, so dump
287
+ * vs follow is logcat's own vocabulary: `['-d']` or `['-t', '50']` exits
288
+ * after the dump, `['-T', '50']` or no arguments follows until
289
+ * {@link LogcatProcess.stop}. 'exit' fires with logcat's exit code.
290
+ */
291
+ logcat: (args?: string[]) => LogcatProcess;
281
292
  /**
282
293
  * Set Android Wi-Fi bandwidth limits in Kbps. Omit a direction to leave it unchanged;
283
294
  * pass `0` to clear that direction's limit.
@@ -1466,6 +1477,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1466
1477
  setCameraVideo,
1467
1478
  clearCameraVideo,
1468
1479
  adbShell,
1480
+ logcat,
1469
1481
  setWifiBandwidth,
1470
1482
  startRecording,
1471
1483
  stopRecording,
@@ -1721,6 +1733,10 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1721
1733
  };
1722
1734
  };
1723
1735
 
1736
+ const logcat = (args: string[] = []): LogcatProcess => {
1737
+ return new LogcatProcess(options.apiUrl, options.token, args);
1738
+ };
1739
+
1724
1740
  const setWifiBandwidth = async (bandwidthOptions: WifiBandwidthOptions): Promise<void> => {
1725
1741
  const request: CommandRequestMap['setWifiBandwidth'] = {};
1726
1742
  if (bandwidthOptions.downKbps !== undefined) {
package/src/ios-client.ts CHANGED
@@ -28,8 +28,10 @@ import { startXcrunShim as startClientXcrunShim } from './ios-shim';
28
28
  import { persistFields, type PersistOption } from './internal/persist-option';
29
29
  import { streamSessionEntries } from './internal/session-stream';
30
30
  import type { SessionLogLine, SessionEvent } from './resources/session-artifacts';
31
+ import { LogStream } from './log-stream';
31
32
 
32
33
  export { type PersistOption } from './internal/persist-option';
34
+ export { LogStream, type LogStreamEvents } from './log-stream';
33
35
 
34
36
  /**
35
37
  * Connection state of the instance client
@@ -1457,135 +1459,6 @@ export class SimctlExecution extends EventEmitter {
1457
1459
  }
1458
1460
  }
1459
1461
 
1460
- /**
1461
- * Handle for a running log stream subscription (app logs or syslog).
1462
- *
1463
- * Uses a dedicated WebSocket connection separate from the main signaling connection.
1464
- * Emits batched log lines every ~500ms when new lines arrive.
1465
- */
1466
- export interface LogStreamEvents {
1467
- lines: (lines: string[]) => void;
1468
- line: (line: string) => void;
1469
- error: (error: Error) => void;
1470
- close: () => void;
1471
- }
1472
-
1473
- /** @internal - Message from log stream WebSocket */
1474
- type LogStreamMessage = {
1475
- type: string;
1476
- id: string;
1477
- lines?: string[];
1478
- error?: string;
1479
- };
1480
-
1481
- /**
1482
- * Log stream with dedicated WebSocket connection.
1483
- * Each LogStream opens its own WebSocket to isolate log traffic from signaling.
1484
- */
1485
- export class LogStream extends EventEmitter {
1486
- private ws: WebSocket | null = null;
1487
- private subscriptionId: string;
1488
- private stopped = false;
1489
- private terminateMessageType: string;
1490
-
1491
- /** @internal */
1492
- constructor(
1493
- private wsUrl: string,
1494
- private subscribeMessage: object,
1495
- terminateMessageType: string,
1496
- subscriptionId: string,
1497
- ) {
1498
- super();
1499
- this.terminateMessageType = terminateMessageType;
1500
- this.subscriptionId = subscriptionId;
1501
- this._connect();
1502
- }
1503
-
1504
- override on<E extends keyof LogStreamEvents>(event: E, listener: LogStreamEvents[E]): this {
1505
- return super.on(event, listener as any);
1506
- }
1507
-
1508
- override once<E extends keyof LogStreamEvents>(event: E, listener: LogStreamEvents[E]): this {
1509
- return super.once(event, listener as any);
1510
- }
1511
-
1512
- override off<E extends keyof LogStreamEvents>(event: E, listener: LogStreamEvents[E]): this {
1513
- return super.off(event, listener as any);
1514
- }
1515
-
1516
- /** Stop the log stream and close the dedicated WebSocket connection */
1517
- stop(): void {
1518
- if (this.stopped) return;
1519
- this.stopped = true;
1520
-
1521
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
1522
- // Send terminate message before closing
1523
- const terminateMsg = { type: this.terminateMessageType, id: this.subscriptionId };
1524
- try {
1525
- this.ws.send(JSON.stringify(terminateMsg));
1526
- } catch {
1527
- // Ignore send errors during shutdown
1528
- }
1529
- this.ws.close();
1530
- }
1531
- this.ws = null;
1532
- this.emit('close');
1533
- }
1534
-
1535
- /** @internal - Establish the dedicated WebSocket connection */
1536
- private _connect(): void {
1537
- const proxyAgent = nodeProxyTransport.getWebSocketAgent(this.wsUrl);
1538
- this.ws = new WebSocket(this.wsUrl, proxyAgent ? { agent: proxyAgent } : {});
1539
-
1540
- this.ws.on('open', () => {
1541
- if (this.stopped) {
1542
- this.ws?.close();
1543
- return;
1544
- }
1545
- // Send subscription message
1546
- this.ws?.send(JSON.stringify(this.subscribeMessage), (err?: Error) => {
1547
- if (err) {
1548
- this.emit('error', err);
1549
- this.stop();
1550
- }
1551
- });
1552
- });
1553
-
1554
- this.ws.on('message', (data: Data) => {
1555
- if (this.stopped) return;
1556
- try {
1557
- const message: LogStreamMessage = JSON.parse(data.toString());
1558
- if (message.error) {
1559
- this.emit('error', new Error(message.error));
1560
- return;
1561
- }
1562
- if (message.lines && message.lines.length > 0) {
1563
- this.emit('lines', message.lines);
1564
- for (const line of message.lines) {
1565
- this.emit('line', line);
1566
- }
1567
- }
1568
- } catch (e) {
1569
- // Ignore parse errors for non-JSON messages
1570
- }
1571
- });
1572
-
1573
- this.ws.on('error', (err: Error) => {
1574
- if (!this.stopped) {
1575
- this.emit('error', err);
1576
- }
1577
- });
1578
-
1579
- this.ws.on('close', () => {
1580
- if (!this.stopped) {
1581
- this.stopped = true;
1582
- this.emit('close');
1583
- }
1584
- this.ws = null;
1585
- });
1586
- }
1587
- }
1588
-
1589
1462
  /**
1590
1463
  * Creates a client for interacting with a Limrun iOS instance
1591
1464
  * @param options Configuration options including webrtcUrl, token and log level
@@ -1675,6 +1548,8 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1675
1548
  }
1676
1549
  if (ws) {
1677
1550
  ws.removeAllListeners();
1551
+ // A retired socket can emit an error when an unfinished handshake is cancelled.
1552
+ ws.on('error', () => {});
1678
1553
  if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
1679
1554
  ws.close();
1680
1555
  }
@@ -0,0 +1,132 @@
1
+ import { EventEmitter } from 'events';
2
+ import { WebSocket, Data } from 'ws';
3
+ import { nodeProxyTransport } from './internal/proxy-transport';
4
+
5
+ /**
6
+ * Handle for a running log stream subscription (app logs, syslog, or logcat).
7
+ *
8
+ * Uses a dedicated WebSocket connection separate from the main signaling connection.
9
+ * Emits batched log lines every ~500ms when new lines arrive.
10
+ */
11
+ export interface LogStreamEvents {
12
+ lines: (lines: string[]) => void;
13
+ line: (line: string) => void;
14
+ error: (error: Error) => void;
15
+ close: () => void;
16
+ }
17
+
18
+ /** @internal - Message from log stream WebSocket */
19
+ type LogStreamMessage = {
20
+ type: string;
21
+ id: string;
22
+ lines?: string[];
23
+ error?: string;
24
+ };
25
+
26
+ /**
27
+ * Log stream with dedicated WebSocket connection.
28
+ * Each LogStream opens its own WebSocket to isolate log traffic from signaling.
29
+ */
30
+ export class LogStream extends EventEmitter {
31
+ private ws: WebSocket | null = null;
32
+ private subscriptionId: string;
33
+ private stopped = false;
34
+ private terminateMessageType: string;
35
+
36
+ /** @internal */
37
+ constructor(
38
+ private wsUrl: string,
39
+ private subscribeMessage: object,
40
+ terminateMessageType: string,
41
+ subscriptionId: string,
42
+ ) {
43
+ super();
44
+ this.terminateMessageType = terminateMessageType;
45
+ this.subscriptionId = subscriptionId;
46
+ this._connect();
47
+ }
48
+
49
+ override on<E extends keyof LogStreamEvents>(event: E, listener: LogStreamEvents[E]): this {
50
+ return super.on(event, listener as any);
51
+ }
52
+
53
+ override once<E extends keyof LogStreamEvents>(event: E, listener: LogStreamEvents[E]): this {
54
+ return super.once(event, listener as any);
55
+ }
56
+
57
+ override off<E extends keyof LogStreamEvents>(event: E, listener: LogStreamEvents[E]): this {
58
+ return super.off(event, listener as any);
59
+ }
60
+
61
+ /** Stop the log stream and close the dedicated WebSocket connection */
62
+ stop(): void {
63
+ if (this.stopped) return;
64
+ this.stopped = true;
65
+
66
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
67
+ // Send terminate message before closing
68
+ const terminateMsg = { type: this.terminateMessageType, id: this.subscriptionId };
69
+ try {
70
+ this.ws.send(JSON.stringify(terminateMsg));
71
+ } catch {
72
+ // Ignore send errors during shutdown
73
+ }
74
+ this.ws.close();
75
+ }
76
+ this.ws = null;
77
+ this.emit('close');
78
+ }
79
+
80
+ /** @internal - Establish the dedicated WebSocket connection */
81
+ private _connect(): void {
82
+ const proxyAgent = nodeProxyTransport.getWebSocketAgent(this.wsUrl);
83
+ this.ws = new WebSocket(this.wsUrl, proxyAgent ? { agent: proxyAgent } : {});
84
+
85
+ this.ws.on('open', () => {
86
+ if (this.stopped) {
87
+ this.ws?.close();
88
+ return;
89
+ }
90
+ // Send subscription message
91
+ this.ws?.send(JSON.stringify(this.subscribeMessage), (err?: Error) => {
92
+ if (err) {
93
+ this.emit('error', err);
94
+ this.stop();
95
+ }
96
+ });
97
+ });
98
+
99
+ this.ws.on('message', (data: Data) => {
100
+ if (this.stopped) return;
101
+ try {
102
+ const message: LogStreamMessage = JSON.parse(data.toString());
103
+ if (message.error) {
104
+ this.emit('error', new Error(message.error));
105
+ return;
106
+ }
107
+ if (message.lines && message.lines.length > 0) {
108
+ this.emit('lines', message.lines);
109
+ for (const line of message.lines) {
110
+ this.emit('line', line);
111
+ }
112
+ }
113
+ } catch {
114
+ // Ignore parse errors for non-JSON messages
115
+ }
116
+ });
117
+
118
+ this.ws.on('error', (err: Error) => {
119
+ if (!this.stopped) {
120
+ this.emit('error', err);
121
+ }
122
+ });
123
+
124
+ this.ws.on('close', () => {
125
+ if (!this.stopped) {
126
+ this.stopped = true;
127
+ this.emit('close');
128
+ }
129
+ this.ws = null;
130
+ });
131
+ }
132
+ }
@@ -0,0 +1,151 @@
1
+ import { EventEmitter } from 'events';
2
+ import { nodeProxyTransport } from './internal/proxy-transport';
3
+ import { streamSessionEntries } from './internal/session-stream';
4
+
5
+ /**
6
+ * Handle for one logcat invocation on an Android instance.
7
+ *
8
+ * The instance runs `logcat` with the caller's arguments and serves its
9
+ * output over a per-run SSE stream (same machinery as session capture
10
+ * streams), batched every ~500ms. Lines arrive via 'lines'/'line'; 'exit'
11
+ * fires once with logcat's exit code, or -1 when stopped or the stream is
12
+ * lost.
13
+ */
14
+ export interface LogcatProcessEvents {
15
+ lines: (lines: string[]) => void;
16
+ line: (line: string) => void;
17
+ error: (error: Error) => void;
18
+ exit: (exitCode: number) => void;
19
+ }
20
+
21
+ /** @internal - One frame of the per-run logcat SSE stream */
22
+ type LogcatEntry = {
23
+ ts: number;
24
+ lines?: string[];
25
+ error?: string;
26
+ exitCode?: number;
27
+ };
28
+
29
+ export class LogcatProcess extends EventEmitter {
30
+ private stopStream: (() => void) | null = null;
31
+ private runId: string | null = null;
32
+ private stopped = false;
33
+ private exited = false;
34
+ private readonly exitPromise: Promise<number>;
35
+ private settleExit!: (code: number) => void;
36
+
37
+ /** @internal */
38
+ constructor(
39
+ private readonly apiUrl: string,
40
+ private readonly token: string,
41
+ args: string[],
42
+ ) {
43
+ super();
44
+ this.exitPromise = new Promise((resolve) => {
45
+ this.settleExit = resolve;
46
+ });
47
+ void this.start(args);
48
+ }
49
+
50
+ override on<E extends keyof LogcatProcessEvents>(event: E, listener: LogcatProcessEvents[E]): this {
51
+ return super.on(event, listener as any);
52
+ }
53
+
54
+ override once<E extends keyof LogcatProcessEvents>(event: E, listener: LogcatProcessEvents[E]): this {
55
+ return super.once(event, listener as any);
56
+ }
57
+
58
+ override off<E extends keyof LogcatProcessEvents>(event: E, listener: LogcatProcessEvents[E]): this {
59
+ return super.off(event, listener as any);
60
+ }
61
+
62
+ /** Resolves with logcat's exit code; -1 when stopped or the stream was lost. */
63
+ wait(): Promise<number> {
64
+ return this.exitPromise;
65
+ }
66
+
67
+ /** Stop the remote logcat process and close the stream. */
68
+ stop(): void {
69
+ if (this.stopped || this.exited) return;
70
+ this.stopped = true;
71
+ this.deleteRun(this.runId);
72
+ this.finish(-1);
73
+ }
74
+
75
+ private deleteRun(runId: string | null): void {
76
+ if (!runId) return;
77
+ // Fire and forget: the server's idle sweep reaps the run if this is lost.
78
+ void nodeProxyTransport
79
+ .fetch(`${this.apiUrl}/logcat/${runId}`, {
80
+ method: 'DELETE',
81
+ headers: { Authorization: `Bearer ${this.token}` },
82
+ })
83
+ .catch(() => {});
84
+ }
85
+
86
+ private finish(code: number): void {
87
+ if (this.exited) return;
88
+ this.exited = true;
89
+ this.stopStream?.();
90
+ this.stopStream = null;
91
+ this.settleExit(code);
92
+ this.emit('exit', code);
93
+ }
94
+
95
+ private fail(error: Error): void {
96
+ if (this.exited) return;
97
+ // The remote process must not outlive a lost stream; the idle sweep is
98
+ // only the fallback for when this delete is lost too.
99
+ this.deleteRun(this.runId);
100
+ this.emit('error', error);
101
+ this.finish(-1);
102
+ }
103
+
104
+ private async start(args: string[]): Promise<void> {
105
+ let id: string;
106
+ try {
107
+ const response = await nodeProxyTransport.fetch(`${this.apiUrl}/logcat`, {
108
+ method: 'POST',
109
+ headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' },
110
+ body: JSON.stringify({ args }),
111
+ });
112
+ if (!response.ok) {
113
+ const text = await response.text().catch(() => '');
114
+ throw new Error(`logcat failed to start: HTTP ${response.status}${text ? ` ${text.trim()}` : ''}`);
115
+ }
116
+ const data = (await response.json()) as { id?: string };
117
+ if (!data.id) {
118
+ throw new Error('logcat failed to start: server returned no run id');
119
+ }
120
+ id = data.id;
121
+ } catch (err) {
122
+ this.fail(err instanceof Error ? err : new Error(String(err)));
123
+ return;
124
+ }
125
+ if (this.stopped) {
126
+ // stop() raced the POST; kill the run it just created.
127
+ this.deleteRun(id);
128
+ return;
129
+ }
130
+ this.runId = id;
131
+ this.stopStream = streamSessionEntries<LogcatEntry>({
132
+ url: `${this.apiUrl}/logcat/${id}/events`,
133
+ token: this.token,
134
+ onEntry: (entry) => {
135
+ if (entry.lines && entry.lines.length > 0) {
136
+ this.emit('lines', entry.lines);
137
+ for (const line of entry.lines) {
138
+ this.emit('line', line);
139
+ }
140
+ }
141
+ if (entry.error) {
142
+ this.emit('error', new Error(entry.error));
143
+ }
144
+ if (typeof entry.exitCode === 'number') {
145
+ this.finish(entry.exitCode);
146
+ }
147
+ },
148
+ onError: (error) => this.fail(error),
149
+ });
150
+ }
151
+ }
@@ -0,0 +1,55 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { parse, stringify, type TomlTable } from 'smol-toml';
4
+
5
+ export function parseToolRequests(requests: string[]): Record<string, string> {
6
+ const tools: Record<string, string> = {};
7
+ for (const request of requests) {
8
+ const at = request.lastIndexOf('@');
9
+ if (at <= 0 || !request.slice(at + 1).trim())
10
+ throw new Error(`Expected tool@version, received ${JSON.stringify(request)}.`);
11
+ const name = request.slice(0, at);
12
+ if (!/^[A-Za-z0-9][A-Za-z0-9:_./-]*$/.test(name))
13
+ throw new Error(`Invalid tool name ${JSON.stringify(name)}.`);
14
+ tools[name] = request.slice(at + 1);
15
+ }
16
+ return tools;
17
+ }
18
+
19
+ async function readConfig(file: string): Promise<TomlTable> {
20
+ try {
21
+ return parse(await fs.readFile(file, 'utf8'));
22
+ } catch (error) {
23
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {};
24
+ throw new Error(`Cannot read mise configuration ${file}: ${String(error)}`);
25
+ }
26
+ }
27
+
28
+ /** Save requested tool versions in the project configuration. */
29
+ export async function writeMiseTools(directory: string, tools: Record<string, string>): Promise<string> {
30
+ let file = path.join(directory, 'mise.toml');
31
+ // Update the highest-precedence existing file in this directory.
32
+ for (const name of ['mise.toml', '.mise.toml', 'mise.local.toml', '.mise.local.toml']) {
33
+ const candidate = path.join(directory, name);
34
+ try {
35
+ await fs.access(candidate);
36
+ file = candidate;
37
+ } catch (error) {
38
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
39
+ }
40
+ }
41
+ const config = await readConfig(file);
42
+ const existing = config['tools'];
43
+ if (existing !== undefined && (!existing || typeof existing !== 'object' || Array.isArray(existing)))
44
+ throw new Error(`Expected [tools] in ${file}.`);
45
+ config['tools'] = { ...(existing as TomlTable | undefined), ...tools };
46
+ await fs.mkdir(path.dirname(file), { recursive: true });
47
+ const temporary = `${file}.${process.pid}.tmp`;
48
+ try {
49
+ await fs.writeFile(temporary, stringify(config), { mode: 0o600, flag: 'wx' });
50
+ await fs.rename(temporary, file);
51
+ } finally {
52
+ await fs.rm(temporary, { force: true });
53
+ }
54
+ return file;
55
+ }
@@ -197,6 +197,11 @@ export namespace AndroidInstanceCreateParams {
197
197
  */
198
198
  jurisdiction?: 'us' | 'eu' | 'as';
199
199
 
200
+ /**
201
+ * The Android device model. Default is phone.
202
+ */
203
+ model?: 'phone' | 'tablet';
204
+
200
205
  /**
201
206
  * Where the instance will be created. If not given, the region is decided based on
202
207
  * scheduling clues (client IP) and availability.