@limrun/api 0.39.3 → 0.39.5

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 (43) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/folder-sync-ignore.d.mts +38 -0
  3. package/folder-sync-ignore.d.mts.map +1 -1
  4. package/folder-sync-ignore.d.ts +38 -0
  5. package/folder-sync-ignore.d.ts.map +1 -1
  6. package/folder-sync-ignore.js +107 -12
  7. package/folder-sync-ignore.js.map +1 -1
  8. package/folder-sync-ignore.mjs +107 -12
  9. package/folder-sync-ignore.mjs.map +1 -1
  10. package/folder-sync.d.mts +6 -0
  11. package/folder-sync.d.mts.map +1 -1
  12. package/folder-sync.d.ts +6 -0
  13. package/folder-sync.d.ts.map +1 -1
  14. package/folder-sync.js +153 -13
  15. package/folder-sync.js.map +1 -1
  16. package/folder-sync.mjs +153 -13
  17. package/folder-sync.mjs.map +1 -1
  18. package/ios-client.d.mts +5 -5
  19. package/ios-client.d.mts.map +1 -1
  20. package/ios-client.d.ts +5 -5
  21. package/ios-client.d.ts.map +1 -1
  22. package/ios-client.js +18 -22
  23. package/ios-client.js.map +1 -1
  24. package/ios-client.mjs +18 -22
  25. package/ios-client.mjs.map +1 -1
  26. package/package.json +1 -1
  27. package/resources/xcode-instances-helpers.d.mts +6 -0
  28. package/resources/xcode-instances-helpers.d.mts.map +1 -1
  29. package/resources/xcode-instances-helpers.d.ts +6 -0
  30. package/resources/xcode-instances-helpers.d.ts.map +1 -1
  31. package/resources/xcode-instances-helpers.js +7 -25
  32. package/resources/xcode-instances-helpers.js.map +1 -1
  33. package/resources/xcode-instances-helpers.mjs +7 -25
  34. package/resources/xcode-instances-helpers.mjs.map +1 -1
  35. package/src/folder-sync-ignore.ts +128 -12
  36. package/src/folder-sync.ts +189 -17
  37. package/src/ios-client.ts +24 -28
  38. package/src/resources/xcode-instances-helpers.ts +13 -29
  39. package/src/version.ts +1 -1
  40. package/version.d.mts +1 -1
  41. package/version.d.ts +1 -1
  42. package/version.js +1 -1
  43. package/version.mjs +1 -1
@@ -47,6 +47,12 @@ export type FolderSyncOptions = {
47
47
  * ignoreFn: (path) => !(path.startsWith('src/') || path.endsWith('.json'))
48
48
  */
49
49
  ignoreFn: IgnoreFn;
50
+ /**
51
+ * Sync symlinks as symlinks (relative, in-root targets only). Off by
52
+ * default: only the limbuild workspace sync understands link entries;
53
+ * the limulator app-install sync keeps the historical skip behavior.
54
+ */
55
+ syncSymlinks?: boolean;
50
56
  /**
51
57
  * Extra files to sync into limbuild. These are included
52
58
  * in every sync pass but are not watched directly.
@@ -77,6 +83,12 @@ type FileEntry = {
77
83
  sha256: string;
78
84
  absPath: string;
79
85
  mode: number;
86
+ /**
87
+ * Symlink target (literal readlink output, forward slashes). Non-empty
88
+ * marks a symlink entry: the target string is the content (sha256/size
89
+ * describe it) and the entry never carries a payload.
90
+ */
91
+ linkTarget?: string;
80
92
  };
81
93
 
82
94
  type FolderSyncHttpPayload = {
@@ -94,7 +106,7 @@ type FolderSyncHttpMeta = {
94
106
  rootName: string;
95
107
  install?: boolean;
96
108
  launchMode?: 'ForegroundIfRunning' | 'RelaunchIfRunning';
97
- files: { path: string; size: number; sha256: string; mode: number }[];
109
+ files: { path: string; size: number; sha256: string; mode?: number; link?: string }[];
98
110
  payloads: FolderSyncHttpPayload[];
99
111
  };
100
112
  type FolderSyncHttpResponse = {
@@ -109,6 +121,10 @@ type FolderSyncHttpResponse = {
109
121
  error?: string;
110
122
  };
111
123
 
124
+ /** Full uploads at or above this size get an info-level notice so "sync is
125
+ * stuck" turns into "a GB-scale file is being uploaded". */
126
+ const LARGE_UPLOAD_NOTICE_BYTES = 64 * 1024 * 1024;
127
+
112
128
  const noopLogger = (_level: 'debug' | 'info' | 'warn' | 'error', _msg: string) => {
113
129
  // Intentionally empty: callers (e.g. ios-client.ts) should provide their own logger
114
130
  // to control verbosity and integrate with the SDK's logging setup.
@@ -269,7 +285,35 @@ async function sha256FileHex(filePath: string): Promise<string> {
269
285
  });
270
286
  }
271
287
 
272
- async function walkFiles(root: string, ignoreFn: IgnoreFn): Promise<FileEntry[]> {
288
+ /**
289
+ * Validates a symlink's readlink target at sync time: it must be relative,
290
+ * backslash-free, and lexically resolve inside the sync root when joined
291
+ * with the link's directory (the daemon enforces the same policy on apply,
292
+ * so failing here is the same failure with a better message).
293
+ */
294
+ function validateSymlinkTarget(rel: string, target: string): void {
295
+ if (target.includes('\\')) {
296
+ throw new Error(
297
+ `symlink ${rel} -> ${target} target contains a backslash, which the daemon rejects; ` +
298
+ 'remove the link or pass --ignore to skip it',
299
+ );
300
+ }
301
+ const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(rel), target));
302
+ if (resolved === '.' || resolved === '..' || resolved.startsWith('../')) {
303
+ throw new Error(
304
+ `symlink ${rel} -> ${target} points outside the sync root; ` +
305
+ 'run the sync from the repo root that contains the target, remove the link, ' +
306
+ 'or pass --ignore to skip it',
307
+ );
308
+ }
309
+ }
310
+
311
+ async function walkFiles(
312
+ root: string,
313
+ ignoreFn: IgnoreFn,
314
+ syncSymlinks: boolean,
315
+ log?: FolderSyncOptions['log'],
316
+ ): Promise<FileEntry[]> {
273
317
  const rootResolved = path.resolve(root);
274
318
  const rootStat = await fs.promises.stat(rootResolved);
275
319
  if (rootStat.isFile()) {
@@ -305,6 +349,36 @@ async function walkFiles(root: string, ignoreFn: IgnoreFn): Promise<FileEntry[]>
305
349
  stack.push(abs);
306
350
  continue;
307
351
  }
352
+ if (ent.isSymbolicLink()) {
353
+ if (!syncSymlinks) continue;
354
+ // Symlinks ship as link entries with the literal target: a
355
+ // symlinked directory arrives here too and is sent as a link, not
356
+ // traversed (git semantics). The target string is the content.
357
+ // Probe both forms: a symlink named e.g. `Pods` or `build` may point
358
+ // at a directory, so directory-only excludes (`Pods/`, `build/`)
359
+ // must match it and skip before validation rejects an out-of-root
360
+ // target.
361
+ if (ignoreFn(rel) || ignoreFn(rel + '/')) continue;
362
+ const target = (await fs.promises.readlink(abs)).split(path.sep).join('/');
363
+ // An absolute target can never resolve remotely and no sync root
364
+ // can contain it; skip it like pre-symlink clients did (with a
365
+ // warning) instead of failing a previously working sync.
366
+ if (path.posix.isAbsolute(target) || path.win32.isAbsolute(target)) {
367
+ log?.('warn', `skipping symlink ${rel} -> ${target}: absolute targets cannot resolve remotely`);
368
+ continue;
369
+ }
370
+ validateSymlinkTarget(rel, target);
371
+ const sha256 = crypto.createHash('sha256').update(target).digest('hex');
372
+ out.push({
373
+ path: rel,
374
+ size: Buffer.byteLength(target),
375
+ sha256,
376
+ absPath: abs,
377
+ mode: 0,
378
+ linkTarget: target,
379
+ });
380
+ continue;
381
+ }
308
382
  if (!ent.isFile()) continue;
309
383
 
310
384
  // Check custom ignores for files
@@ -329,7 +403,9 @@ async function collectAdditionalFiles(
329
403
  }
330
404
  const out: FileEntry[] = [];
331
405
  for (const additionalFile of additionalFiles) {
332
- const remotePath = additionalFile.remotePath;
406
+ // Canonicalize user-supplied remote paths ("./netrc" -> "netrc") so the
407
+ // manifest key matches what the daemon applies and echoes in needFull.
408
+ const remotePath = path.posix.normalize(additionalFile.remotePath);
333
409
  const absPath = path.resolve(additionalFile.localPath);
334
410
  const st = await fs.promises.stat(absPath);
335
411
  if (!st.isFile()) {
@@ -449,9 +525,48 @@ export async function encodeXdelta3Patch(
449
525
  return bytesWritten;
450
526
  }
451
527
 
452
- async function cachePut(cacheRoot: string, relPath: string, srcFile: string): Promise<void> {
453
- const dst = path.join(cacheRoot, relPath.split('/').join(path.sep));
528
+ async function cachePut(
529
+ cacheRoot: string,
530
+ relPath: string,
531
+ srcFile: string,
532
+ linkTarget?: string,
533
+ checkedDirs?: Set<string>,
534
+ ): Promise<void> {
535
+ const segments = relPath.split('/');
536
+ const dst = path.join(cacheRoot, segments.join(path.sep));
537
+ // Remove any ancestor that is not a real directory before mkdir/write: a
538
+ // path that previously synced as a symlink (or a regular file that became
539
+ // a directory) leaves a stale cache inode, and writing children through a
540
+ // symlink would corrupt the link target's cached entries while mkdir over
541
+ // a file throws after the server already applied. Only relevant when
542
+ // symlink sync is on; the caller passes checkedDirs (which also memoizes
543
+ // ancestors already confirmed real, so the sorted walk skips shared
544
+ // prefixes) then and omits it otherwise to skip the sweep entirely.
545
+ if (checkedDirs) {
546
+ let ancestor = cacheRoot;
547
+ for (let i = 0; i < segments.length - 1; i++) {
548
+ ancestor = path.join(ancestor, segments[i]!);
549
+ if (checkedDirs.has(ancestor)) continue;
550
+ const ast = await fs.promises.lstat(ancestor).catch(() => null);
551
+ if (ast && !ast.isDirectory()) {
552
+ await fs.promises.rm(ancestor, { recursive: true, force: true });
553
+ } else if (ast) {
554
+ checkedDirs.add(ancestor);
555
+ }
556
+ }
557
+ }
454
558
  await fs.promises.mkdir(path.dirname(dst), { recursive: true });
559
+ // Replace the destination inode when its type doesn't match what we're
560
+ // writing: a stale symlink (copyFile would follow it) or a stale directory
561
+ // (copyFile onto a dir throws EISDIR) left by a since-changed path.
562
+ const st = await fs.promises.lstat(dst).catch(() => null);
563
+ if (st && (st.isSymbolicLink() || st.isDirectory() || linkTarget !== undefined)) {
564
+ await fs.promises.rm(dst, { recursive: true, force: true });
565
+ }
566
+ if (linkTarget !== undefined) {
567
+ await fs.promises.symlink(linkTarget, dst);
568
+ return;
569
+ }
455
570
  await fs.promises.copyFile(srcFile, dst);
456
571
  }
457
572
 
@@ -567,7 +682,7 @@ async function syncFolderOnce(
567
682
  const log = opts.log ?? noopLogger;
568
683
  const slog = (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => log(level, `syncFolder: ${msg}`);
569
684
 
570
- const files = await walkFiles(localFolderPath, opts.ignoreFn);
685
+ const files = await walkFiles(localFolderPath, opts.ignoreFn, opts.syncSymlinks ?? false, slog);
571
686
  const additionalFiles = await collectAdditionalFiles(opts.additionalFiles);
572
687
  const allFiles = [...files, ...additionalFiles].sort((a, b) => a.path.localeCompare(b.path));
573
688
  const fileMap = new Map(allFiles.map((f) => [f.path, f]));
@@ -587,11 +702,30 @@ async function syncFolderOnce(
587
702
  type EncodedPayload = { payload: FolderSyncHttpPayload; filePath: string; cleanupDir?: string };
588
703
 
589
704
  // Build payload list by comparing against local basis cache (single-flight/watch assumes server matches cache).
705
+ // lstat, never stat/existsSync: those follow symlinks and misreport a
706
+ // basis symlink (dangling or pointing at another cached file) as the
707
+ // file itself.
590
708
  const encodeLimit = concurrencyLimit();
591
709
  const changed: FileEntry[] = [];
592
710
  for (const f of allFiles) {
593
711
  const basisPath = cacheGet(opts.basisCacheDir, f.path);
594
- if (!fs.existsSync(basisPath)) {
712
+ const basisStat = await fs.promises.lstat(basisPath).catch(() => null);
713
+ if (!basisStat) {
714
+ changed.push(f);
715
+ continue;
716
+ }
717
+ if (f.linkTarget !== undefined) {
718
+ if (!basisStat.isSymbolicLink()) {
719
+ changed.push(f);
720
+ continue;
721
+ }
722
+ const basisTarget = (await fs.promises.readlink(basisPath)).split(path.sep).join('/');
723
+ if (basisTarget !== f.linkTarget) {
724
+ changed.push(f);
725
+ }
726
+ continue;
727
+ }
728
+ if (!basisStat.isFile()) {
595
729
  changed.push(f);
596
730
  continue;
597
731
  }
@@ -601,9 +735,17 @@ async function syncFolderOnce(
601
735
  }
602
736
  }
603
737
 
604
- const encodedPayloads = await mapLimit(changed, encodeLimit, async (f): Promise<EncodedPayload> => {
738
+ // Symlink entries travel in the manifest only, with no payloads.
739
+ const changedFiles = changed.filter((f) => f.linkTarget === undefined);
740
+ // Grows in the needFull retry below; sized here so the summary counts
741
+ // every path that actually shipped this sync.
742
+ const changedPaths = new Set(changed.map((f) => f.path));
743
+
744
+ const encodedPayloads = await mapLimit(changedFiles, encodeLimit, async (f): Promise<EncodedPayload> => {
605
745
  const basisPath = cacheGet(opts.basisCacheDir, f.path);
606
- if (f.size >= MIN_XDELTA_TARGET_BYTES && fs.existsSync(basisPath)) {
746
+ const basisStat =
747
+ f.size >= MIN_XDELTA_TARGET_BYTES ? await fs.promises.lstat(basisPath).catch(() => null) : null;
748
+ if (basisStat?.isFile()) {
607
749
  const basisSha = await sha256FileHex(basisPath);
608
750
  const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'limulator-xdelta3-'));
609
751
  const patchPath = path.join(tmpDir, 'patch.xdelta3');
@@ -642,6 +784,9 @@ async function syncFolderOnce(
642
784
  }
643
785
  }
644
786
  slog('debug', `full(file): ${f.path} size=${f.size}`);
787
+ if (f.size >= LARGE_UPLOAD_NOTICE_BYTES) {
788
+ slog('info', `uploading large file ${f.path} (${fmtBytes(f.size)})`);
789
+ }
645
790
  bytesSentFull += f.size;
646
791
  return {
647
792
  payload: {
@@ -663,7 +808,8 @@ async function syncFolderOnce(
663
808
  path: f.path,
664
809
  size: f.size,
665
810
  sha256: f.sha256.toLowerCase(),
666
- mode: f.mode,
811
+ // Symlink entries carry the target; regular files carry the mode bits.
812
+ ...(f.linkTarget !== undefined ? { link: f.linkTarget } : { mode: f.mode }),
667
813
  })),
668
814
  payloads: encodedPayloads.map((p) => p.payload),
669
815
  };
@@ -696,10 +842,29 @@ async function syncFolderOnce(
696
842
  // Retry once if server needs full for some paths (basis mismatch).
697
843
  if (!resp.ok && resp.needFull && resp.needFull.length > 0) {
698
844
  const need = new Set(resp.needFull);
845
+ // A daemon that predates symlink support json-drops the `link` field and
846
+ // asks for these entries as full files. Streaming absPath would FOLLOW
847
+ // the link and silently materialize target content remotely, so fail loud
848
+ // instead. (New daemons never put symlink entries in needFull.)
849
+ const needFullLinks = [...need].filter((p) => fileMap.get(p)?.linkTarget !== undefined);
850
+ if (needFullLinks.length > 0) {
851
+ throw new Error(
852
+ "this instance's build daemon does not support symlinks yet (needs an updated limbuild); " +
853
+ `recreate the instance or retry later. Paths: ${needFullLinks.join(', ')}`,
854
+ );
855
+ }
699
856
  const retryPayloads: EncodedPayload[] = [];
700
857
  for (const p of need) {
701
858
  const entry = fileMap.get(p);
702
859
  if (!entry) continue;
860
+ // The retry uploads real bytes: count them (and surface large files)
861
+ // exactly like first-pass fulls, or a fresh-daemon resync of a warm
862
+ // cache reports "sent=0B" while gigabytes upload.
863
+ if (entry.size >= LARGE_UPLOAD_NOTICE_BYTES) {
864
+ slog('info', `uploading large file ${entry.path} (${fmtBytes(entry.size)})`);
865
+ }
866
+ bytesSentFull += entry.size;
867
+ changedPaths.add(entry.path);
703
868
  retryPayloads.push({
704
869
  payload: {
705
870
  kind: 'full',
@@ -709,10 +874,12 @@ async function syncFolderOnce(
709
874
  },
710
875
  filePath: entry.absPath,
711
876
  });
712
- bytesSentFull += entry.size;
713
877
  }
714
878
  if (retryPayloads.length > 0) {
715
- slog('debug', `server requested full for ${retryPayloads.length} files; retrying once`);
879
+ slog(
880
+ 'info',
881
+ `daemon requested a full upload for ${retryPayloads.length} files (no matching basis); retrying once`,
882
+ );
716
883
  const retryMeta: FolderSyncHttpMeta = {
717
884
  ...meta,
718
885
  id: genId('sync'),
@@ -749,11 +916,12 @@ async function syncFolderOnce(
749
916
  const tookMs = nowMs() - totalStart;
750
917
  const totalBytes = bytesSentFull + bytesSentDelta;
751
918
  slog(
752
- 'debug',
753
- `sync finished files=${allFiles.length} sent=${fmtBytes(totalBytes)} syncWork=${fmtMs(
754
- syncWorkMs,
755
- )} total=${fmtMs(tookMs)}`,
919
+ 'info',
920
+ `sync complete: files=${allFiles.length} changed=${changedPaths.size} sent=${fmtBytes(
921
+ totalBytes,
922
+ )} in ${fmtMs(tookMs)}`,
756
923
  );
924
+ slog('debug', `sync timing syncWork=${fmtMs(syncWorkMs)} total=${fmtMs(tookMs)}`);
757
925
  const out: SyncFolderResult = { bytesSent: totalBytes };
758
926
  if (resp.installedAppPath) {
759
927
  out.installedAppPath = resp.installedAppPath;
@@ -762,8 +930,12 @@ async function syncFolderOnce(
762
930
  out.installedBundleId = resp.bundleId;
763
931
  }
764
932
  // Update local cache optimistically: after a successful sync, cache reflects current local tree.
933
+ // The stale-symlink-ancestor sweep only matters when symlink sync is on
934
+ // (otherwise the cache never holds a symlink); checkedDirs memoizes ancestors
935
+ // across the sorted walk.
936
+ const checkedDirs = opts.syncSymlinks ? new Set<string>() : undefined;
765
937
  for (const f of allFiles) {
766
- await cachePut(opts.basisCacheDir, f.path, f.absPath);
938
+ await cachePut(opts.basisCacheDir, f.path, f.absPath, f.linkTarget, checkedDirs);
767
939
  }
768
940
  return out;
769
941
  }
package/src/ios-client.ts CHANGED
@@ -270,17 +270,17 @@ export type DetoxLaunchRuntime = {
270
270
 
271
271
  export type LaunchAppRuntime = DetoxLaunchRuntime;
272
272
 
273
- export type LaunchAppShutdownCallback = (exitCode: number, logs: string[]) => Promise<void>;
273
+ export type LaunchAppExitCallback = (logs: string[]) => Promise<void>;
274
274
 
275
- type LaunchAppShutdownOptions = {
275
+ type LaunchAppExitOptions = {
276
276
  /**
277
277
  * Called when the launched app exits. The logs array contains one entry per
278
278
  * stdout/stderr line produced by that execution, as reported by limulator.
279
279
  */
280
- onShutdown?: LaunchAppShutdownCallback;
280
+ onExit?: LaunchAppExitCallback;
281
281
  };
282
282
 
283
- type StandardLaunchAppOptions = LaunchAppShutdownOptions & {
283
+ type StandardLaunchAppOptions = LaunchAppExitOptions & {
284
284
  /**
285
285
  * Launch behavior when the app may already be running.
286
286
  * Defaults to `ForegroundIfRunning` server-side.
@@ -289,7 +289,7 @@ type StandardLaunchAppOptions = LaunchAppShutdownOptions & {
289
289
  runtime?: undefined;
290
290
  };
291
291
 
292
- type RuntimeLaunchAppOptions = LaunchAppShutdownOptions & {
292
+ type RuntimeLaunchAppOptions = LaunchAppExitOptions & {
293
293
  /** Runtime launches must relaunch so runtime injection is applied. */
294
294
  mode?: Extract<LaunchAppMode, 'RelaunchIfRunning'>;
295
295
  /** Optional app runtime to attach during launch. */
@@ -970,7 +970,7 @@ type ServerResponse = {
970
970
  exitCode?: number;
971
971
  // Log tail fields
972
972
  logs?: string;
973
- // App shutdown fields
973
+ // App exit fields
974
974
  execId?: string;
975
975
  logLineCount?: number;
976
976
  // App log streaming fields
@@ -1546,33 +1546,31 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1546
1546
  return logs.split(/\r?\n/);
1547
1547
  };
1548
1548
 
1549
- const handleAppShutdown = (message: ServerResponse): boolean => {
1550
- if (message.type !== 'appShutdown') {
1549
+ const handleAppExit = (message: ServerResponse): boolean => {
1550
+ if (message.type !== 'appExit') {
1551
1551
  return false;
1552
1552
  }
1553
1553
 
1554
- const { execId, bundleId, exitCode, logLineCount } = message;
1554
+ const { execId, bundleId, logLineCount } = message;
1555
1555
  if (
1556
1556
  typeof execId !== 'string' ||
1557
1557
  typeof bundleId !== 'string' ||
1558
- typeof exitCode !== 'number' ||
1559
- !Number.isInteger(exitCode) ||
1560
1558
  typeof logLineCount !== 'number' ||
1561
1559
  !Number.isInteger(logLineCount) ||
1562
1560
  logLineCount < 0
1563
1561
  ) {
1564
- logger.warn('Received malformed appShutdown message:', message);
1562
+ logger.warn('Received malformed appExit message:', message);
1565
1563
  return true;
1566
1564
  }
1567
1565
 
1568
- const notification = takeServerNotification('appShutdown', execId);
1566
+ const notification = takeServerNotification('appExit', execId);
1569
1567
  if (!notification) {
1570
- logger.debug(`Received appShutdown for unknown or already handled execId: ${execId}`);
1568
+ logger.debug(`Received appExit for unknown or already handled execId: ${execId}`);
1571
1569
  return true;
1572
1570
  }
1573
1571
 
1574
1572
  void notification(message).catch((error) => {
1575
- logger.error(`Error processing appShutdown notification for execId ${execId}:`, error);
1573
+ logger.error(`Error processing appExit notification for execId ${execId}:`, error);
1576
1574
  });
1577
1575
 
1578
1576
  return true;
@@ -1652,7 +1650,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1652
1650
  return;
1653
1651
  }
1654
1652
 
1655
- if (handleAppShutdown(message)) {
1653
+ if (handleAppExit(message)) {
1656
1654
  return;
1657
1655
  }
1658
1656
 
@@ -1912,20 +1910,18 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1912
1910
  );
1913
1911
  }
1914
1912
  const mode = launchOptions.runtime ? 'RelaunchIfRunning' : launchOptions.mode;
1915
- const onShutdown = launchOptions.onShutdown;
1916
- const execId = onShutdown ? generateId() : undefined;
1917
- if (execId && onShutdown) {
1918
- registerServerNotification('appShutdown', execId, async (message) => {
1919
- const { bundleId, exitCode, logLineCount } = message;
1913
+ const onExit = launchOptions.onExit;
1914
+ const execId = onExit ? generateId() : undefined;
1915
+ if (execId && onExit) {
1916
+ registerServerNotification('appExit', execId, async (message) => {
1917
+ const { bundleId, logLineCount } = message;
1920
1918
  if (
1921
1919
  typeof bundleId !== 'string' ||
1922
- typeof exitCode !== 'number' ||
1923
- !Number.isInteger(exitCode) ||
1924
1920
  typeof logLineCount !== 'number' ||
1925
1921
  !Number.isInteger(logLineCount) ||
1926
1922
  logLineCount < 0
1927
1923
  ) {
1928
- logger.warn(`Received malformed appShutdown payload for execId ${execId}:`, message);
1924
+ logger.warn(`Received malformed appExit payload for execId ${execId}:`, message);
1929
1925
  return;
1930
1926
  }
1931
1927
 
@@ -1935,13 +1931,13 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1935
1931
  await sendRequest<string>('appLogTail', { bundleId, lines: logLineCount }),
1936
1932
  );
1937
1933
  } catch (error) {
1938
- logger.error(`Failed to fetch app logs for shutdown execId ${execId}:`, error);
1934
+ logger.error(`Failed to fetch app logs for exit execId ${execId}:`, error);
1939
1935
  }
1940
1936
 
1941
1937
  try {
1942
- await onShutdown(exitCode, logs);
1938
+ await onExit(logs);
1943
1939
  } catch (error) {
1944
- logger.error(`Error in onShutdown callback for execId ${execId}:`, error);
1940
+ logger.error(`Error in onExit callback for execId ${execId}:`, error);
1945
1941
  }
1946
1942
  });
1947
1943
  }
@@ -1952,7 +1948,7 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1952
1948
  execId,
1953
1949
  }).catch((error) => {
1954
1950
  if (execId) {
1955
- deleteServerNotification('appShutdown', execId);
1951
+ deleteServerNotification('appExit', execId);
1956
1952
  }
1957
1953
  throw error;
1958
1954
  });
@@ -45,6 +45,12 @@ export type SyncOptions = {
45
45
  * Return true to ignore, false to keep.
46
46
  */
47
47
  ignore?: (relativePath: string) => boolean;
48
+ /**
49
+ * Optional predicate for force-including paths that would otherwise be
50
+ * dropped (gitignored generated sources, default Xcode excludes). Same
51
+ * calling convention as `ignore`; return true to force-sync the path.
52
+ */
53
+ include?: (relativePath: string) => boolean;
48
54
  /**
49
55
  * Extra files to sync on every sync pass.
50
56
  */
@@ -596,40 +602,18 @@ export class XcodeInstances extends GeneratedXcodeInstances {
596
602
  ignoreFn: await createIgnoreFn(localCodePath, {
597
603
  basisCacheDir,
598
604
  log,
599
- additional: (relativePath: string) => {
600
- if (
601
- relativePath.startsWith('build/') ||
602
- relativePath.startsWith('.build/') ||
603
- relativePath.startsWith('DerivedData/') ||
604
- relativePath.startsWith('Index.noindex/') ||
605
- relativePath.startsWith('ModuleCache.noindex/') ||
606
- relativePath.startsWith('.index-build/')
607
- ) {
608
- return true;
609
- }
610
- if (
611
- relativePath.startsWith('.swiftpm/') ||
612
- relativePath.startsWith('Pods/') ||
613
- relativePath.startsWith('Carthage/Build/')
614
- ) {
615
- return true;
616
- }
617
- if (relativePath.includes('/xcuserdata/')) {
618
- return true;
619
- }
620
- if (relativePath.includes('.dSYM/')) {
621
- return true;
622
- }
623
- if (opts?.ignore?.(relativePath)) {
624
- return true;
625
- }
626
- return false;
627
- },
605
+ xcodeDefaults: true,
606
+ ...(opts?.include ? { include: opts.include } : {}),
607
+ ...(opts?.ignore ? { additional: opts.ignore } : {}),
628
608
  }),
629
609
  basisCacheDir,
630
610
  watch: opts?.watch ?? true,
631
611
  launchMode: 'ForegroundIfRunning',
632
612
  log,
613
+ // The limbuild workspace sync understands symlink entries; the
614
+ // limulator app-install sync (ios-client.ts) does not and keeps
615
+ // the default skip behavior.
616
+ syncSymlinks: true,
633
617
  ...(additionalFiles ? { additionalFiles } : {}),
634
618
  };
635
619
 
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.39.3'; // x-release-please-version
1
+ export const VERSION = '0.39.5'; // x-release-please-version
package/version.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.39.3";
1
+ export declare const VERSION = "0.39.5";
2
2
  //# sourceMappingURL=version.d.mts.map
package/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.39.3";
1
+ export declare const VERSION = "0.39.5";
2
2
  //# sourceMappingURL=version.d.ts.map
package/version.js CHANGED
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
- exports.VERSION = '0.39.3'; // x-release-please-version
4
+ exports.VERSION = '0.39.5'; // x-release-please-version
5
5
  //# sourceMappingURL=version.js.map
package/version.mjs CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '0.39.3'; // x-release-please-version
1
+ export const VERSION = '0.39.5'; // x-release-please-version
2
2
  //# sourceMappingURL=version.mjs.map