@limrun/api 0.19.3 → 0.20.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 +15 -0
- package/client.d.mts +1 -0
- package/client.d.mts.map +1 -1
- package/client.d.ts +1 -0
- package/client.d.ts.map +1 -1
- package/client.js +10 -2
- package/client.js.map +1 -1
- package/client.mjs +10 -2
- package/client.mjs.map +1 -1
- package/exec-client.d.mts +101 -0
- package/exec-client.d.mts.map +1 -0
- package/exec-client.d.ts +101 -0
- package/exec-client.d.ts.map +1 -0
- package/exec-client.js +265 -0
- package/exec-client.js.map +1 -0
- package/exec-client.mjs +259 -0
- package/exec-client.mjs.map +1 -0
- package/folder-sync.d.mts +16 -2
- package/folder-sync.d.mts.map +1 -1
- package/folder-sync.d.ts +16 -2
- package/folder-sync.d.ts.map +1 -1
- package/folder-sync.js +43 -14
- package/folder-sync.js.map +1 -1
- package/folder-sync.mjs +43 -13
- package/folder-sync.mjs.map +1 -1
- package/index.d.mts +2 -0
- package/index.d.mts.map +1 -1
- package/index.d.ts +2 -0
- package/index.d.ts.map +1 -1
- package/index.js +5 -1
- package/index.js.map +1 -1
- package/index.mjs +2 -0
- package/index.mjs.map +1 -1
- package/internal/parse.d.mts.map +1 -1
- package/internal/parse.d.ts.map +1 -1
- package/internal/parse.js +5 -0
- package/internal/parse.js.map +1 -1
- package/internal/parse.mjs +5 -0
- package/internal/parse.mjs.map +1 -1
- package/ios-client.d.mts +10 -3
- package/ios-client.d.mts.map +1 -1
- package/ios-client.d.ts +10 -3
- package/ios-client.d.ts.map +1 -1
- package/ios-client.js +19 -4
- package/ios-client.js.map +1 -1
- package/ios-client.mjs +18 -3
- package/ios-client.mjs.map +1 -1
- package/package.json +23 -1
- package/sandbox-client.d.mts +124 -0
- package/sandbox-client.d.mts.map +1 -0
- package/sandbox-client.d.ts +124 -0
- package/sandbox-client.d.ts.map +1 -0
- package/sandbox-client.js +149 -0
- package/sandbox-client.js.map +1 -0
- package/sandbox-client.mjs +146 -0
- package/sandbox-client.mjs.map +1 -0
- package/src/client.ts +10 -2
- package/src/exec-client.ts +333 -0
- package/src/folder-sync.ts +66 -18
- package/src/index.ts +16 -0
- package/src/internal/parse.ts +6 -0
- package/src/ios-client.ts +35 -5
- package/src/sandbox-client.ts +267 -0
- 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
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for executing commands on limbuild server with streaming output.
|
|
3
|
+
*
|
|
4
|
+
* The interface is designed to be similar to Node.js's child_process.spawn()
|
|
5
|
+
* for familiarity and ease of extension.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createEventSource, type EventSourceClient, type EventSourceMessage } from 'eventsource-client';
|
|
9
|
+
|
|
10
|
+
// =============================================================================
|
|
11
|
+
// Types
|
|
12
|
+
// =============================================================================
|
|
13
|
+
|
|
14
|
+
export type ExecRequest = {
|
|
15
|
+
command: 'xcodebuild';
|
|
16
|
+
xcodebuild?: {
|
|
17
|
+
workspace?: string;
|
|
18
|
+
project?: string;
|
|
19
|
+
scheme?: string;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ExecOptions = {
|
|
24
|
+
apiUrl: string;
|
|
25
|
+
token: string;
|
|
26
|
+
log?: (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ExecResult = {
|
|
30
|
+
exitCode: number;
|
|
31
|
+
execId: string;
|
|
32
|
+
status: 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type DataListener = (chunk: string) => void;
|
|
36
|
+
type CloseListener = () => void;
|
|
37
|
+
type ExitListener = (code: number) => void;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A Readable-like stream interface, similar to Node.js stream.Readable.
|
|
41
|
+
* Emits 'data' for each chunk and 'close' when the stream ends.
|
|
42
|
+
*/
|
|
43
|
+
export class ReadableStream {
|
|
44
|
+
private dataListeners: DataListener[] = [];
|
|
45
|
+
private closeListeners: CloseListener[] = [];
|
|
46
|
+
private closed = false;
|
|
47
|
+
|
|
48
|
+
on(event: 'data', listener: DataListener): this;
|
|
49
|
+
on(event: 'close', listener: CloseListener): this;
|
|
50
|
+
on(event: 'data' | 'close', listener: DataListener | CloseListener): this {
|
|
51
|
+
if (event === 'data') {
|
|
52
|
+
this.dataListeners.push(listener as DataListener);
|
|
53
|
+
} else if (event === 'close') {
|
|
54
|
+
this.closeListeners.push(listener as CloseListener);
|
|
55
|
+
}
|
|
56
|
+
return this;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @internal */
|
|
60
|
+
emit(event: 'data', chunk: string): void;
|
|
61
|
+
emit(event: 'close'): void;
|
|
62
|
+
emit(event: 'data' | 'close', arg?: string): void {
|
|
63
|
+
if (event === 'data' && typeof arg === 'string') {
|
|
64
|
+
for (const l of this.dataListeners) l(arg);
|
|
65
|
+
} else if (event === 'close' && !this.closed) {
|
|
66
|
+
this.closed = true;
|
|
67
|
+
for (const l of this.closeListeners) l();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A ChildProcess-like object similar to Node.js's ChildProcess.
|
|
74
|
+
*
|
|
75
|
+
* Implements PromiseLike so it can be awaited directly.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* // Stream-based (like Node.js spawn)
|
|
79
|
+
* const proc = exec({ command: 'xcodebuild' }, options);
|
|
80
|
+
* proc.stdout.on('data', (chunk) => process.stdout.write(chunk));
|
|
81
|
+
* proc.stderr.on('data', (chunk) => process.stderr.write(chunk));
|
|
82
|
+
* proc.on('exit', (code) => console.log(`Exited with code ${code}`));
|
|
83
|
+
*
|
|
84
|
+
* // Promise-based (can be awaited)
|
|
85
|
+
* const { exitCode, status } = await proc;
|
|
86
|
+
*/
|
|
87
|
+
export class ExecChildProcess implements PromiseLike<ExecResult> {
|
|
88
|
+
/** Stdout stream - emits 'data' and 'close' events */
|
|
89
|
+
readonly stdout = new ReadableStream();
|
|
90
|
+
|
|
91
|
+
/** Stderr stream - emits 'data' and 'close' events */
|
|
92
|
+
readonly stderr = new ReadableStream();
|
|
93
|
+
|
|
94
|
+
/** The remote process/build identifier (similar to pid in Node.js) */
|
|
95
|
+
execId: string | undefined;
|
|
96
|
+
|
|
97
|
+
private readonly resultPromise: Promise<ExecResult>;
|
|
98
|
+
private readonly exitListeners: ExitListener[] = [];
|
|
99
|
+
private abortController: AbortController | null = null;
|
|
100
|
+
private sseConnection: EventSourceClient | null = null;
|
|
101
|
+
private killed = false;
|
|
102
|
+
private readonly options: ExecOptions;
|
|
103
|
+
private readonly log: (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
|
|
104
|
+
|
|
105
|
+
constructor(request: ExecRequest, options: ExecOptions) {
|
|
106
|
+
this.options = options;
|
|
107
|
+
this.log = options.log ?? (() => {});
|
|
108
|
+
this.resultPromise = this.run(request);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Implement PromiseLike so this object can be awaited */
|
|
112
|
+
then<TResult1 = ExecResult, TResult2 = never>(
|
|
113
|
+
onfulfilled?: ((value: ExecResult) => TResult1 | PromiseLike<TResult1>) | null,
|
|
114
|
+
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
115
|
+
): Promise<TResult1 | TResult2> {
|
|
116
|
+
return this.resultPromise.then(onfulfilled, onrejected);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Catch errors */
|
|
120
|
+
catch<TResult = never>(
|
|
121
|
+
onrejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null,
|
|
122
|
+
): Promise<ExecResult | TResult> {
|
|
123
|
+
return this.resultPromise.catch(onrejected);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Finally handler */
|
|
127
|
+
finally(onfinally?: (() => void) | null): Promise<ExecResult> {
|
|
128
|
+
return this.resultPromise.finally(onfinally);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Listen for process events */
|
|
132
|
+
on(event: 'exit', listener: ExitListener): this {
|
|
133
|
+
if (event === 'exit') {
|
|
134
|
+
this.exitListeners.push(listener);
|
|
135
|
+
}
|
|
136
|
+
return this;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Send a signal to terminate the process */
|
|
140
|
+
async kill(): Promise<void> {
|
|
141
|
+
this.killed = true;
|
|
142
|
+
if (this.abortController) {
|
|
143
|
+
this.abortController.abort();
|
|
144
|
+
}
|
|
145
|
+
if (this.sseConnection) {
|
|
146
|
+
this.sseConnection.close();
|
|
147
|
+
this.sseConnection = null;
|
|
148
|
+
}
|
|
149
|
+
if (!this.execId) {
|
|
150
|
+
this.log('warn', 'Failed to cancel build: execId is not set');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
await fetch(`${this.options.apiUrl}/exec/${this.execId}/cancel`, {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: {
|
|
157
|
+
Authorization: `Bearer ${this.options.token}`,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
this.log('info', 'Build cancelled');
|
|
161
|
+
} catch (err) {
|
|
162
|
+
this.log('warn', `Failed to cancel build: ${err}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async run(request: ExecRequest): Promise<ExecResult> {
|
|
167
|
+
const { log } = this;
|
|
168
|
+
const { apiUrl, token } = this.options;
|
|
169
|
+
|
|
170
|
+
// 1. Trigger the build via POST /exec
|
|
171
|
+
log('debug', `POST ${apiUrl}/exec`);
|
|
172
|
+
const execRes = await fetch(`${apiUrl}/exec`, {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: {
|
|
175
|
+
'Content-Type': 'application/json',
|
|
176
|
+
Authorization: `Bearer ${token}`,
|
|
177
|
+
},
|
|
178
|
+
body: JSON.stringify(request),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (!execRes.ok) {
|
|
182
|
+
const text = await execRes.text();
|
|
183
|
+
throw new Error(`exec failed: ${execRes.status} ${text}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const execData = (await execRes.json()) as { execId: string };
|
|
187
|
+
this.execId = execData.execId;
|
|
188
|
+
log('info', `Build started: ${this.execId}`);
|
|
189
|
+
|
|
190
|
+
// 2. Connect to SSE for log streaming and completion detection
|
|
191
|
+
this.abortController = new AbortController();
|
|
192
|
+
const eventsUrl = `${apiUrl}/exec/${this.execId}/events`;
|
|
193
|
+
log('debug', `GET ${eventsUrl} (SSE)`);
|
|
194
|
+
|
|
195
|
+
// Promise that resolves when build completes (via exitCode event)
|
|
196
|
+
let sseCompletionResolve: ((exitCode: number) => void) | null = null;
|
|
197
|
+
const sseCompletionPromise = new Promise<number>((resolve) => {
|
|
198
|
+
sseCompletionResolve = resolve;
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const ssePromise = this.connectSSE(eventsUrl, sseCompletionResolve);
|
|
202
|
+
|
|
203
|
+
// Wait for SSE to signal completion (with timeout fallback)
|
|
204
|
+
const timeoutMs = 3600 * 1000; // 1 hour max
|
|
205
|
+
let exitCode: number;
|
|
206
|
+
try {
|
|
207
|
+
exitCode = await Promise.race([
|
|
208
|
+
sseCompletionPromise,
|
|
209
|
+
new Promise<number>((_, reject) => setTimeout(() => reject(new Error('SSE timeout')), timeoutMs)),
|
|
210
|
+
]);
|
|
211
|
+
} catch {
|
|
212
|
+
log('warn', 'SSE completion timeout');
|
|
213
|
+
exitCode = 1;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Cleanup SSE connection
|
|
217
|
+
if (this.abortController) {
|
|
218
|
+
this.abortController.abort();
|
|
219
|
+
}
|
|
220
|
+
await ssePromise.catch(() => {});
|
|
221
|
+
|
|
222
|
+
// Emit close events on streams
|
|
223
|
+
this.stdout.emit('close');
|
|
224
|
+
this.stderr.emit('close');
|
|
225
|
+
|
|
226
|
+
// Emit exit event
|
|
227
|
+
for (const listener of this.exitListeners) {
|
|
228
|
+
listener(exitCode);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Determine status from exit code
|
|
232
|
+
const status: 'SUCCEEDED' | 'FAILED' | 'CANCELLED' =
|
|
233
|
+
exitCode === 0 ? 'SUCCEEDED'
|
|
234
|
+
: exitCode === -1 ? 'CANCELLED'
|
|
235
|
+
: 'FAILED';
|
|
236
|
+
|
|
237
|
+
const result: ExecResult = {
|
|
238
|
+
exitCode,
|
|
239
|
+
execId: this.execId!,
|
|
240
|
+
status,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
this.log('info', `Build finished: ${result.status} (exit ${result.exitCode})`);
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private async connectSSE(
|
|
248
|
+
eventsUrl: string,
|
|
249
|
+
onComplete: ((exitCode: number) => void) | null,
|
|
250
|
+
): Promise<void> {
|
|
251
|
+
return new Promise((resolve) => {
|
|
252
|
+
const authHeader = `Bearer ${this.options.token}`;
|
|
253
|
+
|
|
254
|
+
let resolved = false;
|
|
255
|
+
const resolveOnce = () => {
|
|
256
|
+
if (resolved) return;
|
|
257
|
+
resolved = true;
|
|
258
|
+
this.sseConnection?.close();
|
|
259
|
+
this.sseConnection = null;
|
|
260
|
+
resolve();
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const eventSource = createEventSource({
|
|
265
|
+
url: eventsUrl,
|
|
266
|
+
headers: { Authorization: authHeader },
|
|
267
|
+
onMessage: (message: EventSourceMessage) => {
|
|
268
|
+
const data = typeof message.data === 'string' ? message.data : String(message.data ?? '');
|
|
269
|
+
const eventType = message.event;
|
|
270
|
+
if (eventType === 'stdout') {
|
|
271
|
+
this.stdout.emit('data', data);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (eventType === 'stderr') {
|
|
275
|
+
this.stderr.emit('data', data);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (eventType === 'exitCode') {
|
|
279
|
+
const exitCode = parseInt(data, 10);
|
|
280
|
+
if (Number.isNaN(exitCode)) {
|
|
281
|
+
this.log('warn', `SSE exitCode event has invalid data: ${data}`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
this.log('debug', `Build completed via SSE: exitCode=${exitCode}`);
|
|
285
|
+
onComplete?.(exitCode);
|
|
286
|
+
resolveOnce();
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
onDisconnect: () => {
|
|
290
|
+
if (!this.killed) {
|
|
291
|
+
this.log('warn', 'SSE disconnected');
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
this.sseConnection = eventSource;
|
|
296
|
+
|
|
297
|
+
const abortSignal = this.abortController?.signal;
|
|
298
|
+
if (abortSignal) {
|
|
299
|
+
abortSignal.addEventListener(
|
|
300
|
+
'abort',
|
|
301
|
+
() => {
|
|
302
|
+
resolveOnce();
|
|
303
|
+
},
|
|
304
|
+
{ once: true },
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
} catch (err) {
|
|
308
|
+
if (!this.killed) {
|
|
309
|
+
this.log('warn', `SSE setup failed: ${err}`);
|
|
310
|
+
}
|
|
311
|
+
resolveOnce();
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Execute a command on the limbuild server.
|
|
319
|
+
* Returns a ChildProcess-like object with stdout/stderr streams.
|
|
320
|
+
*
|
|
321
|
+
* @example
|
|
322
|
+
* const proc = exec({ command: 'xcodebuild' }, { apiUrl: '...', token: '...' });
|
|
323
|
+
*
|
|
324
|
+
* // Stream output
|
|
325
|
+
* proc.stdout.on('data', (chunk) => console.log('[stdout]', chunk));
|
|
326
|
+
* proc.stderr.on('data', (chunk) => console.error('[stderr]', chunk));
|
|
327
|
+
*
|
|
328
|
+
* // Wait for completion
|
|
329
|
+
* const { exitCode, status } = await proc;
|
|
330
|
+
*/
|
|
331
|
+
export function exec(request: ExecRequest, options: ExecOptions): ExecChildProcess {
|
|
332
|
+
return new ExecChildProcess(request, options);
|
|
333
|
+
}
|
package/src/folder-sync.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { spawn } from 'child_process';
|
|
|
6
6
|
import { watchFolderTree } from './folder-sync-watcher';
|
|
7
7
|
import { Readable } from 'stream';
|
|
8
8
|
import * as zlib from 'zlib';
|
|
9
|
+
import ignore, { type Ignore } from 'ignore';
|
|
9
10
|
|
|
10
11
|
// =============================================================================
|
|
11
12
|
// Folder Sync (HTTP batch)
|
|
@@ -20,7 +21,7 @@ export type FolderSyncOptions = {
|
|
|
20
21
|
* Used to store the last-synced “basis” copies of files (and related sync metadata) so we can compute xdelta patches
|
|
21
22
|
* on subsequent syncs without re-downloading server state.
|
|
22
23
|
*
|
|
23
|
-
* Can be absolute or relative to process.cwd(). Defaults to `.
|
|
24
|
+
* Can be absolute or relative to process.cwd(). Defaults to `.limsync-cache/`.
|
|
24
25
|
*/
|
|
25
26
|
basisCacheDir?: string;
|
|
26
27
|
install?: boolean;
|
|
@@ -31,6 +32,21 @@ export type FolderSyncOptions = {
|
|
|
31
32
|
maxPatchBytes?: number;
|
|
32
33
|
/** Controls logging verbosity */
|
|
33
34
|
log?: (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
|
|
35
|
+
/**
|
|
36
|
+
* Optional filter function to include/exclude files and directories.
|
|
37
|
+
* Called with the relative path from localFolderPath (using forward slashes).
|
|
38
|
+
* For directories, the path ends with '/'.
|
|
39
|
+
* Return true to include, false to exclude.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* // Exclude build folder
|
|
43
|
+
* filter: (path) => !path.startsWith('build/')
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* // Only include source files
|
|
47
|
+
* filter: (path) => path.startsWith('src/') || path.endsWith('.json')
|
|
48
|
+
*/
|
|
49
|
+
filter?: (relativePath: string) => boolean;
|
|
34
50
|
};
|
|
35
51
|
|
|
36
52
|
export type SyncFolderResult = {
|
|
@@ -63,6 +79,10 @@ type FolderSyncHttpMeta = {
|
|
|
63
79
|
type FolderSyncHttpResponse = {
|
|
64
80
|
ok: boolean;
|
|
65
81
|
needFull?: string[];
|
|
82
|
+
// Timing fields
|
|
83
|
+
syncDurationMs?: number;
|
|
84
|
+
installDurationMs?: number; // limulator only
|
|
85
|
+
// Install result fields (limulator only)
|
|
66
86
|
installedAppPath?: string;
|
|
67
87
|
bundleId?: string;
|
|
68
88
|
error?: string;
|
|
@@ -123,7 +143,7 @@ async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promis
|
|
|
123
143
|
}
|
|
124
144
|
|
|
125
145
|
function folderSyncHttpUrl(apiUrl: string): string {
|
|
126
|
-
return `${apiUrl}/
|
|
146
|
+
return `${apiUrl}/sync`;
|
|
127
147
|
}
|
|
128
148
|
|
|
129
149
|
function u32be(n: number): Buffer {
|
|
@@ -226,23 +246,42 @@ async function sha256FileHex(filePath: string): Promise<string> {
|
|
|
226
246
|
});
|
|
227
247
|
}
|
|
228
248
|
|
|
229
|
-
async function walkFiles(root: string): Promise<FileEntry[]> {
|
|
230
|
-
const out: FileEntry[] = [];
|
|
231
|
-
const stack: string[] = [root];
|
|
249
|
+
async function walkFiles(root: string, filter?: (relativePath: string) => boolean): Promise<FileEntry[]> {
|
|
232
250
|
const rootResolved = path.resolve(root);
|
|
251
|
+
|
|
252
|
+
// Load .gitignore if it exists
|
|
253
|
+
const ig = await loadGitignore(rootResolved);
|
|
254
|
+
|
|
255
|
+
const out: FileEntry[] = [];
|
|
256
|
+
const stack: string[] = [rootResolved];
|
|
233
257
|
while (stack.length) {
|
|
234
258
|
const dir = stack.pop()!;
|
|
235
259
|
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
236
260
|
for (const ent of entries) {
|
|
237
|
-
|
|
261
|
+
// Always skip .git folder and .DS_Store
|
|
262
|
+
if (ent.name === '.DS_Store' || ent.name === '.git') continue;
|
|
263
|
+
|
|
238
264
|
const abs = path.join(dir, ent.name);
|
|
265
|
+
const rel = path.relative(rootResolved, abs).split(path.sep).join('/');
|
|
266
|
+
|
|
267
|
+
// Check if ignored by .gitignore
|
|
268
|
+
if (ig.ignores(rel)) continue;
|
|
269
|
+
|
|
239
270
|
if (ent.isDirectory()) {
|
|
271
|
+
// For directories, check with trailing slash
|
|
272
|
+
const relDir = rel + '/';
|
|
273
|
+
if (ig.ignores(relDir)) continue;
|
|
274
|
+
// Check custom filter (directories have trailing slash)
|
|
275
|
+
if (filter && !filter(relDir)) continue;
|
|
240
276
|
stack.push(abs);
|
|
241
277
|
continue;
|
|
242
278
|
}
|
|
243
279
|
if (!ent.isFile()) continue;
|
|
280
|
+
|
|
281
|
+
// Check custom filter for files
|
|
282
|
+
if (filter && !filter(rel)) continue;
|
|
283
|
+
|
|
244
284
|
const st = await fs.promises.stat(abs);
|
|
245
|
-
const rel = path.relative(rootResolved, abs).split(path.sep).join('/');
|
|
246
285
|
const sha256 = await sha256FileHex(abs);
|
|
247
286
|
// Preserve POSIX permission bits (including +x). Mask out file-type bits.
|
|
248
287
|
const mode = st.mode & 0o7777;
|
|
@@ -253,6 +292,22 @@ async function walkFiles(root: string): Promise<FileEntry[]> {
|
|
|
253
292
|
return out;
|
|
254
293
|
}
|
|
255
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Load and parse .gitignore file if it exists.
|
|
297
|
+
* Returns an Ignore instance that can be used to test paths.
|
|
298
|
+
*/
|
|
299
|
+
async function loadGitignore(rootDir: string): Promise<Ignore> {
|
|
300
|
+
const ig = ignore();
|
|
301
|
+
const gitignorePath = path.join(rootDir, '.gitignore');
|
|
302
|
+
try {
|
|
303
|
+
const content = await fs.promises.readFile(gitignorePath, 'utf-8');
|
|
304
|
+
ig.add(content);
|
|
305
|
+
} catch {
|
|
306
|
+
// No .gitignore file, return empty ignore instance
|
|
307
|
+
}
|
|
308
|
+
return ig;
|
|
309
|
+
}
|
|
310
|
+
|
|
256
311
|
let xdelta3Ready: Promise<void> | null = null;
|
|
257
312
|
async function ensureXdelta3(): Promise<void> {
|
|
258
313
|
if (!xdelta3Ready) {
|
|
@@ -291,7 +346,7 @@ function localBasisCacheRoot(opts: FolderSyncOptions, localFolderPath: string):
|
|
|
291
346
|
const rootOverride =
|
|
292
347
|
opts.basisCacheDir ?
|
|
293
348
|
path.resolve(process.cwd(), opts.basisCacheDir)
|
|
294
|
-
: path.join(process.cwd(), '.
|
|
349
|
+
: path.join(process.cwd(), '.limsync-cache');
|
|
295
350
|
// Include folder identity to avoid collisions between different roots.
|
|
296
351
|
return path.join(rootOverride, 'folder-sync', hostKey, opts.udid, `${base}-${hash}`);
|
|
297
352
|
}
|
|
@@ -310,7 +365,8 @@ export type SyncAppResult = SyncFolderResult;
|
|
|
310
365
|
|
|
311
366
|
export async function syncApp(localFolderPath: string, opts: FolderSyncOptions): Promise<SyncFolderResult> {
|
|
312
367
|
if (!opts.watch) {
|
|
313
|
-
|
|
368
|
+
const result = await syncFolderOnce(localFolderPath, opts);
|
|
369
|
+
return result;
|
|
314
370
|
}
|
|
315
371
|
// Initial sync, then watch for changes and re-run sync in the background.
|
|
316
372
|
const first = await syncFolderOnce(localFolderPath, opts, 'startup');
|
|
@@ -353,14 +409,6 @@ export async function syncApp(localFolderPath: string, opts: FolderSyncOptions):
|
|
|
353
409
|
};
|
|
354
410
|
}
|
|
355
411
|
|
|
356
|
-
// Back-compat alias (older callers)
|
|
357
|
-
export async function syncFolder(
|
|
358
|
-
localFolderPath: string,
|
|
359
|
-
opts: FolderSyncOptions,
|
|
360
|
-
): Promise<SyncFolderResult> {
|
|
361
|
-
return await syncApp(localFolderPath, opts);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
412
|
async function syncFolderOnce(
|
|
365
413
|
localFolderPath: string,
|
|
366
414
|
opts: FolderSyncOptions,
|
|
@@ -377,7 +425,7 @@ async function syncFolderOnce(
|
|
|
377
425
|
const tEnsureMs = nowMs() - tEnsureStart;
|
|
378
426
|
|
|
379
427
|
const tWalkStart = nowMs();
|
|
380
|
-
const files = await walkFiles(localFolderPath);
|
|
428
|
+
const files = await walkFiles(localFolderPath, opts.filter);
|
|
381
429
|
const tWalkMs = nowMs() - tWalkStart;
|
|
382
430
|
const fileMap = new Map(files.map((f) => [f.path, f]));
|
|
383
431
|
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,22 @@ export { Limrun, type ClientOptions } from './client';
|
|
|
8
8
|
export { PagePromise } from './core/pagination';
|
|
9
9
|
export * from './instance-client';
|
|
10
10
|
export * as Ios from './ios-client';
|
|
11
|
+
export {
|
|
12
|
+
createXCodeSandboxClient,
|
|
13
|
+
type XCodeSandboxClient,
|
|
14
|
+
type CreateXCodeSandboxClientOptions,
|
|
15
|
+
type SimulatorConfig,
|
|
16
|
+
type SyncOptions,
|
|
17
|
+
type SyncResult,
|
|
18
|
+
type XcodeBuildConfig,
|
|
19
|
+
} from './sandbox-client';
|
|
20
|
+
export {
|
|
21
|
+
exec,
|
|
22
|
+
type ExecRequest,
|
|
23
|
+
type ExecOptions,
|
|
24
|
+
type ExecResult,
|
|
25
|
+
type ExecChildProcess,
|
|
26
|
+
} from './exec-client';
|
|
11
27
|
export {
|
|
12
28
|
LimrunError,
|
|
13
29
|
APIError,
|
package/src/internal/parse.ts
CHANGED
|
@@ -29,6 +29,12 @@ export async function defaultParseResponse<T>(client: Limrun, props: APIResponse
|
|
|
29
29
|
const mediaType = contentType?.split(';')[0]?.trim();
|
|
30
30
|
const isJSON = mediaType?.includes('application/json') || mediaType?.endsWith('+json');
|
|
31
31
|
if (isJSON) {
|
|
32
|
+
const contentLength = response.headers.get('content-length');
|
|
33
|
+
if (contentLength === '0') {
|
|
34
|
+
// if there is no content we can't do anything
|
|
35
|
+
return undefined as T;
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
const json = await response.json();
|
|
33
39
|
return json as T;
|
|
34
40
|
}
|
package/src/ios-client.ts
CHANGED
|
@@ -153,12 +153,22 @@ export type InstanceClient = {
|
|
|
153
153
|
elementTree: (point?: AccessibilityPoint) => Promise<string>;
|
|
154
154
|
|
|
155
155
|
/**
|
|
156
|
-
* Tap at the specified coordinates
|
|
156
|
+
* Tap at the specified coordinates (uses device's native screen dimensions)
|
|
157
157
|
* @param x X coordinate in points
|
|
158
158
|
* @param y Y coordinate in points
|
|
159
159
|
*/
|
|
160
160
|
tap: (x: number, y: number) => Promise<void>;
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Tap at coordinates with explicit screen size.
|
|
164
|
+
* Use this when coordinates are in a different coordinate space than the device's native dimensions.
|
|
165
|
+
* @param x X coordinate in the provided screen coordinate space
|
|
166
|
+
* @param y Y coordinate in the provided screen coordinate space
|
|
167
|
+
* @param screenWidth Width of the coordinate space
|
|
168
|
+
* @param screenHeight Height of the coordinate space
|
|
169
|
+
*/
|
|
170
|
+
tapWithScreenSize: (x: number, y: number, screenWidth: number, screenHeight: number) => Promise<void>;
|
|
171
|
+
|
|
162
172
|
/**
|
|
163
173
|
* Tap an accessibility element by selector
|
|
164
174
|
* @param selector The selector criteria to find the element
|
|
@@ -805,9 +815,6 @@ export class LogStream extends EventEmitter {
|
|
|
805
815
|
}
|
|
806
816
|
}
|
|
807
817
|
|
|
808
|
-
/** @deprecated Use LogStream instead */
|
|
809
|
-
export const AppLogStream = LogStream;
|
|
810
|
-
|
|
811
818
|
/**
|
|
812
819
|
* Creates a client for interacting with a Limrun iOS instance
|
|
813
820
|
* @param options Configuration options including webrtcUrl, token and log level
|
|
@@ -1159,6 +1166,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1159
1166
|
screenshot,
|
|
1160
1167
|
elementTree,
|
|
1161
1168
|
tap,
|
|
1169
|
+
tapWithScreenSize,
|
|
1162
1170
|
tapElement,
|
|
1163
1171
|
incrementElement,
|
|
1164
1172
|
decrementElement,
|
|
@@ -1202,7 +1210,29 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
|
|
|
1202
1210
|
};
|
|
1203
1211
|
|
|
1204
1212
|
const tap = (x: number, y: number): Promise<void> => {
|
|
1205
|
-
return sendRequest<void>('tap', {
|
|
1213
|
+
return sendRequest<void>('tap', {
|
|
1214
|
+
x,
|
|
1215
|
+
y,
|
|
1216
|
+
screenWidth: cachedDeviceInfo?.screenWidth,
|
|
1217
|
+
screenHeight: cachedDeviceInfo?.screenHeight,
|
|
1218
|
+
});
|
|
1219
|
+
};
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Tap at coordinates with explicit screen size.
|
|
1223
|
+
* Use this when coordinates are in a different coordinate space than the device's native dimensions.
|
|
1224
|
+
* @param x - X coordinate in the provided screen coordinate space
|
|
1225
|
+
* @param y - Y coordinate in the provided screen coordinate space
|
|
1226
|
+
* @param screenWidth - Width of the coordinate space
|
|
1227
|
+
* @param screenHeight - Height of the coordinate space
|
|
1228
|
+
*/
|
|
1229
|
+
const tapWithScreenSize = (
|
|
1230
|
+
x: number,
|
|
1231
|
+
y: number,
|
|
1232
|
+
screenWidth: number,
|
|
1233
|
+
screenHeight: number,
|
|
1234
|
+
): Promise<void> => {
|
|
1235
|
+
return sendRequest<void>('tap', { x, y, screenWidth, screenHeight });
|
|
1206
1236
|
};
|
|
1207
1237
|
|
|
1208
1238
|
const tapElement = (selector: AccessibilitySelector): Promise<TapElementResult> => {
|