@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.
- package/CHANGELOG.md +49 -0
- package/exec-client.d.mts +25 -1
- package/exec-client.d.mts.map +1 -1
- package/exec-client.d.ts +25 -1
- package/exec-client.d.ts.map +1 -1
- package/exec-client.js +362 -197
- package/exec-client.js.map +1 -1
- package/exec-client.mjs +361 -197
- package/exec-client.mjs.map +1 -1
- package/folder-sync-ignore.d.mts +15 -12
- package/folder-sync-ignore.d.mts.map +1 -1
- package/folder-sync-ignore.d.ts +15 -12
- package/folder-sync-ignore.d.ts.map +1 -1
- package/folder-sync-ignore.js +16 -19
- package/folder-sync-ignore.js.map +1 -1
- package/folder-sync-ignore.mjs +16 -19
- package/folder-sync-ignore.mjs.map +1 -1
- package/index.d.mts +1 -1
- package/index.d.mts.map +1 -1
- package/index.d.ts +1 -1
- package/index.d.ts.map +1 -1
- package/index.js.map +1 -1
- package/index.mjs.map +1 -1
- package/instance-client.d.mts +11 -0
- package/instance-client.d.mts.map +1 -1
- package/instance-client.d.ts +11 -0
- package/instance-client.d.ts.map +1 -1
- package/instance-client.js +10 -1
- package/instance-client.js.map +1 -1
- package/instance-client.mjs +7 -0
- package/instance-client.mjs.map +1 -1
- package/ios-client.d.mts +2 -33
- package/ios-client.d.mts.map +1 -1
- package/ios-client.d.ts +2 -33
- package/ios-client.d.ts.map +1 -1
- package/ios-client.js +8 -99
- package/ios-client.js.map +1 -1
- package/ios-client.mjs +4 -95
- package/ios-client.mjs.map +1 -1
- package/log-stream.d.mts +35 -0
- package/log-stream.d.mts.map +1 -0
- package/log-stream.d.ts +35 -0
- package/log-stream.d.ts.map +1 -0
- package/log-stream.js +103 -0
- package/log-stream.js.map +1 -0
- package/log-stream.mjs +99 -0
- package/log-stream.mjs.map +1 -0
- package/logcat-process.d.mts +40 -0
- package/logcat-process.d.mts.map +1 -0
- package/logcat-process.d.ts +40 -0
- package/logcat-process.d.ts.map +1 -0
- package/logcat-process.js +122 -0
- package/logcat-process.js.map +1 -0
- package/logcat-process.mjs +118 -0
- package/logcat-process.mjs.map +1 -0
- package/mise-tools.d.mts +4 -0
- package/mise-tools.d.mts.map +1 -0
- package/mise-tools.d.ts +4 -0
- package/mise-tools.d.ts.map +1 -0
- package/mise-tools.js +63 -0
- package/mise-tools.js.map +1 -0
- package/mise-tools.mjs +58 -0
- package/mise-tools.mjs.map +1 -0
- package/package.json +32 -1
- package/resources/android-instances.d.mts +4 -0
- package/resources/android-instances.d.mts.map +1 -1
- package/resources/android-instances.d.ts +4 -0
- package/resources/android-instances.d.ts.map +1 -1
- package/resources/daemon-client-shared.d.mts +11 -0
- package/resources/daemon-client-shared.d.mts.map +1 -1
- package/resources/daemon-client-shared.d.ts +11 -0
- package/resources/daemon-client-shared.d.ts.map +1 -1
- package/resources/daemon-client-shared.js +23 -0
- package/resources/daemon-client-shared.js.map +1 -1
- package/resources/daemon-client-shared.mjs +22 -0
- package/resources/daemon-client-shared.mjs.map +1 -1
- package/resources/gradle-instances-helpers.d.mts +12 -2
- package/resources/gradle-instances-helpers.d.mts.map +1 -1
- package/resources/gradle-instances-helpers.d.ts +12 -2
- package/resources/gradle-instances-helpers.d.ts.map +1 -1
- package/resources/gradle-instances-helpers.js +6 -0
- package/resources/gradle-instances-helpers.js.map +1 -1
- package/resources/gradle-instances-helpers.mjs +8 -2
- package/resources/gradle-instances-helpers.mjs.map +1 -1
- package/resources/xcode-instances-helpers.d.mts +6 -15
- package/resources/xcode-instances-helpers.d.mts.map +1 -1
- package/resources/xcode-instances-helpers.d.ts +6 -15
- package/resources/xcode-instances-helpers.d.ts.map +1 -1
- package/resources/xcode-instances-helpers.js +3 -18
- package/resources/xcode-instances-helpers.js.map +1 -1
- package/resources/xcode-instances-helpers.mjs +5 -20
- package/resources/xcode-instances-helpers.mjs.map +1 -1
- package/src/exec-client.ts +404 -198
- package/src/folder-sync-ignore.ts +21 -21
- package/src/index.ts +1 -0
- package/src/instance-client.ts +16 -0
- package/src/ios-client.ts +4 -129
- package/src/log-stream.ts +132 -0
- package/src/logcat-process.ts +151 -0
- package/src/mise-tools.ts +55 -0
- package/src/resources/android-instances.ts +5 -0
- package/src/resources/daemon-client-shared.ts +33 -0
- package/src/resources/gradle-instances-helpers.ts +23 -0
- package/src/resources/xcode-instances-helpers.ts +13 -33
- package/src/version.ts +1 -1
- package/version.d.mts +1 -1
- package/version.d.ts +1 -1
- package/version.js +1 -1
- 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
|
-
/**
|
|
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
|
|
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
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
-
*
|
|
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).
|
|
119
|
-
*
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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';
|
package/src/instance-client.ts
CHANGED
|
@@ -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.
|