@limrun/api 0.39.4 → 0.39.6

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/instance-client.d.mts +1 -0
  19. package/instance-client.d.mts.map +1 -1
  20. package/instance-client.d.ts +1 -0
  21. package/instance-client.d.ts.map +1 -1
  22. package/instance-client.js +3 -2
  23. package/instance-client.js.map +1 -1
  24. package/instance-client.mjs +3 -2
  25. package/instance-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/instance-client.ts +11 -1
  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
@@ -2,7 +2,7 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { execFile } from 'child_process';
4
4
  import { promisify } from 'util';
5
- import ignorePkg from 'ignore';
5
+ import ignorePkg, { type Ignore } from 'ignore';
6
6
 
7
7
  const execFileAsync = promisify(execFile);
8
8
 
@@ -11,7 +11,26 @@ 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
  additional?: IgnoreFn;
16
+ /**
17
+ * User-supplied force-includes (the CLI's --include): matches sync even when
18
+ * gitignored or covered by the default Xcode excludes. Only `.git`,
19
+ * `.DS_Store`, and the basis cache stay excluded. Note: the walk prunes
20
+ * gitignore-excluded directories, so to rescue files under a wholly-ignored
21
+ * parent the predicate must also match the parent directory paths (probed
22
+ * with a trailing slash), e.g. `^ios/` reaches `ios/GeneratedKit/...` but
23
+ * `GeneratedKit/` alone does not.
24
+ */
25
+ include?: IgnoreFn;
26
+ /**
27
+ * "Xcode project sync mode." Enables the default Xcode/dependency excludes
28
+ * (build/, Pods/, xcuserdata/, …) and nested-.gitignore honoring. When
29
+ * unset (e.g. the app-bundle install sync) only the root .gitignore is
30
+ * read, so a build artifact isn't reshaped by gitignore files embedded
31
+ * in it.
32
+ */
33
+ xcodeDefaults?: boolean;
15
34
  basisCacheDir: string;
16
35
  log?: IgnoreLogger;
17
36
  };
@@ -52,21 +71,98 @@ async function getGitTrackedSets(rootDir: string): Promise<GitTrackedSets | null
52
71
  }
53
72
  }
54
73
 
74
+ // Root-anchored (top-level only) default excludes; nested build junk is the
75
+ // project's own nested .gitignore's job. xcuserdata/.dSYM below are any-depth.
76
+ const XCODE_DEFAULT_EXCLUDE_PREFIXES = [
77
+ 'build/',
78
+ '.build/',
79
+ 'DerivedData/',
80
+ 'Index.noindex/',
81
+ 'ModuleCache.noindex/',
82
+ '.index-build/',
83
+ '.swiftpm/',
84
+ 'Pods/',
85
+ 'Carthage/Build/',
86
+ ];
87
+
88
+ /**
89
+ * Builds the layered sync ignore predicate. First decisive answer wins:
90
+ *
91
+ * 1. `.git`, `.DS_Store`, the basis cache: excluded, never overridable.
92
+ * 2. User include (--include): explicit intent beats the default excludes.
93
+ * 3. Default Xcode/dependency excludes (when xcodeDefaults is set).
94
+ * 4. Built-in force-include: `*.xcconfig` (gitignored xcconfigs are still
95
+ * required to reproduce the build remotely). Gitignored projects are NOT
96
+ * force-included: limbuild regenerates them from project.yml, and
97
+ * exact-version holdouts force-sync theirs with `--include`.
98
+ * 5. `.gitignore` chain: the root file, plus nested ones with git semantics
99
+ * when xcodeDefaults is set (rules bind relative to their containing
100
+ * directory, deeper files override shallower ones). Only a decisive
101
+ * *exclude* short-circuits; a negation re-include defers to layer 6.
102
+ * Directory pruning in the walk means only layers 2 and 4 can reach a
103
+ * file whose parent directory is gitignore-excluded, and only when the
104
+ * predicate matches the pruned parent directory paths too.
105
+ * 6. User ignore (--ignore).
106
+ */
55
107
  export async function createIgnoreFn(rootDir: string, options: IgnoreFnOptions): Promise<IgnoreFn> {
56
108
  const rootResolved = path.resolve(rootDir);
109
+
110
+ // Per-directory .gitignore instances, lazily loaded; key is the dir path
111
+ // relative to the root ('' = root), value null when the dir has none.
57
112
  // ignorecase: false matches git's default semantics. The package defaults to true,
58
113
  // which silently drops e.g. `Vendor/` when .gitignore says `vendor/` (Ruby convention).
59
114
  // allowRelativePaths: true so a `../`-style path doesn't throw mid-sync — treat it as
60
115
  // "not ignored" and let it through, since the cost of dropping a needed file is higher
61
116
  // than including an unexpected one.
62
- const ig = ignorePkg({ ignorecase: false, allowRelativePaths: true });
63
- const gitignorePath = path.join(rootResolved, '.gitignore');
64
- try {
65
- const content = await fs.promises.readFile(gitignorePath, 'utf-8');
66
- ig.add(content);
67
- } catch {
68
- // No .gitignore file, return empty ignore instance
69
- }
117
+ const dirIgnores = new Map<string, Ignore | null>();
118
+ const gitignoreFor = (dirRel: string): Ignore | null => {
119
+ let ig = dirIgnores.get(dirRel);
120
+ if (ig !== undefined) return ig;
121
+ try {
122
+ const content = fs.readFileSync(
123
+ path.join(rootResolved, dirRel.split('/').join(path.sep), '.gitignore'),
124
+ 'utf-8',
125
+ );
126
+ ig = ignorePkg({ ignorecase: false, allowRelativePaths: true }).add(content);
127
+ } catch {
128
+ ig = null;
129
+ }
130
+ dirIgnores.set(dirRel, ig);
131
+ return ig;
132
+ };
133
+
134
+ // xcodeDefaults selects "Xcode project sync mode": besides the default junk
135
+ // excludes it also enables nested-.gitignore honoring and the project-file
136
+ // force-includes below. Callers that don't set it (e.g. the app-bundle
137
+ // install sync) keep the legacy behavior: root-only .gitignore and no
138
+ // project force-includes, so a build artifact isn't reshaped by gitignore
139
+ // files or Xcode-specific rules that happen to sit inside it.
140
+ const nestedGitignore = !!options.xcodeDefaults;
141
+
142
+ // Evaluates the .gitignore chain for a path (trailing slash preserved so
143
+ // dir-only rules like `build/` match the directory itself). Files are
144
+ // consulted root-to-deepest; the last decisive match wins, so deeper
145
+ // files naturally override shallower ones. When nestedGitignore is off,
146
+ // only the root .gitignore is consulted.
147
+ const gitignoreDecision = (testPath: string): { ignored: boolean; rule?: string } | undefined => {
148
+ const withoutTrailingSlash = testPath.replace(/\/+$/, '');
149
+ const trailingSlash = testPath.endsWith('/') ? '/' : '';
150
+ const parts = withoutTrailingSlash.split('/');
151
+ let decision: { ignored: boolean; rule?: string } | undefined;
152
+ const depth = nestedGitignore ? parts.length : 1;
153
+ for (let i = 0; i < depth; i++) {
154
+ const ig = gitignoreFor(parts.slice(0, i).join('/'));
155
+ if (!ig) continue;
156
+ const result = ig.test(parts.slice(i).join('/') + trailingSlash);
157
+ if (result.ignored || result.unignored) {
158
+ decision = { ignored: result.ignored };
159
+ const rule = (result as { rule?: { pattern?: string } }).rule?.pattern;
160
+ if (rule !== undefined) decision.rule = rule;
161
+ }
162
+ }
163
+ return decision;
164
+ };
165
+
70
166
  const basisCacheRelative = normalizeRelativePath(
71
167
  path.relative(rootResolved, options.basisCacheDir),
72
168
  ).replace(/\/+$/, '');
@@ -86,6 +182,7 @@ export async function createIgnoreFn(rootDir: string, options: IgnoreFnOptions):
86
182
  if (!normalized) return false;
87
183
  const withoutTrailingSlash = normalized.replace(/\/+$/, '');
88
184
 
185
+ // 1. Never overridable.
89
186
  if (
90
187
  withoutTrailingSlash === '.git' ||
91
188
  withoutTrailingSlash.startsWith('.git/') ||
@@ -103,16 +200,34 @@ export async function createIgnoreFn(rootDir: string, options: IgnoreFnOptions):
103
200
  ) {
104
201
  return true;
105
202
  }
203
+ // 2. User include.
204
+ if (options.include?.(normalized)) return false;
205
+ // 3. Default Xcode/dependency excludes.
206
+ if (options.xcodeDefaults) {
207
+ for (const prefix of XCODE_DEFAULT_EXCLUDE_PREFIXES) {
208
+ if (normalized.startsWith(prefix)) return true;
209
+ }
210
+ if (normalized.includes('/xcuserdata/') || normalized.includes('.dSYM/')) return true;
211
+ }
212
+ // 4. Built-in force-include: gitignored xcconfigs are still required to
213
+ // reproduce the build remotely. Gitignored .xcodeproj bundles are NOT
214
+ // force-included: limbuild regenerates them from project.yml, and
215
+ // exact-version holdouts force-sync theirs with --include.
106
216
  if (withoutTrailingSlash.endsWith('.xcconfig')) return false;
107
- if (ig.ignores(normalized)) {
217
+ // 5. The .gitignore chain. Only a decisive *exclude* short-circuits; a
218
+ // negation re-include (decision.ignored === false) still falls through to
219
+ // the user --ignore layer, matching the pre-restructure precedence where
220
+ // gitignore never overrode --ignore for a re-included path.
221
+ const decision = gitignoreDecision(normalized);
222
+ if (decision?.ignored) {
108
223
  if (
109
224
  trackedSets &&
110
225
  (trackedSets.tracked.has(withoutTrailingSlash) || trackedSets.prefixes.has(withoutTrailingSlash))
111
226
  ) {
112
- const rule = ig.test(normalized).rule?.pattern ?? '<unknown>';
227
+ const rule = decision.rule ?? '<unknown>';
113
228
  if (!warnedRules.has(rule)) {
114
229
  warnedRules.add(rule);
115
- const msg = `.gitignore rule '${rule}' is dropping '${withoutTrailingSlash}', which is tracked in git. The remote build will not see this path. Remove or scope the rule if you need it synced.`;
230
+ const msg = `.gitignore rule '${rule}' is dropping '${withoutTrailingSlash}', which is tracked in git. The remote build will not see this path. Remove or scope the rule, or pass --include, if you need it synced.`;
116
231
  if (log) {
117
232
  log('warn', msg);
118
233
  } else {
@@ -122,6 +237,7 @@ export async function createIgnoreFn(rootDir: string, options: IgnoreFnOptions):
122
237
  }
123
238
  return true;
124
239
  }
240
+ // 6. User ignore.
125
241
  if (options.additional?.(normalized)) return true;
126
242
  return false;
127
243
  };
@@ -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
  }
@@ -96,6 +96,7 @@ async function bootstrapAndroidBasisCache(
96
96
  apiUrl: string,
97
97
  token: string,
98
98
  log: FolderSyncOptions['log'],
99
+ onBasisDownload?: (sizeBytes?: number) => void,
99
100
  ): Promise<void> {
100
101
  const remotePath = path.basename(apkPath);
101
102
  const basisPath = path.join(basisCacheDir, remotePath);
@@ -130,6 +131,7 @@ async function bootstrapAndroidBasisCache(
130
131
  if (!seed) {
131
132
  return;
132
133
  }
134
+ onBasisDownload?.(seed.size);
133
135
  await downloadFileToLocalPath(buildSyncSeedUrl(apiUrl, seed.sha256), token, basisPath);
134
136
  log('debug', `seeded Android basis cache from instance seed: ${seed.sha256}`);
135
137
  }
@@ -237,6 +239,7 @@ export type InstanceClient = {
237
239
  watch?: boolean;
238
240
  launchMode?: 'ForegroundIfRunning' | 'RelaunchIfRunning';
239
241
  basisCacheDir?: string;
242
+ onBasisDownload?: (sizeBytes?: number) => void;
240
243
  },
241
244
  ) => Promise<SyncFolderResult>;
242
245
 
@@ -1294,7 +1297,14 @@ export async function createInstanceClient(options: InstanceClientOptions): Prom
1294
1297
  }
1295
1298
  };
1296
1299
  await fs.promises.mkdir(basisCacheDir, { recursive: true });
1297
- await bootstrapAndroidBasisCache(resolvedPath, basisCacheDir, options.apiUrl, options.token, syncLog);
1300
+ await bootstrapAndroidBasisCache(
1301
+ resolvedPath,
1302
+ basisCacheDir,
1303
+ options.apiUrl,
1304
+ options.token,
1305
+ syncLog,
1306
+ syncOpts?.onBasisDownload,
1307
+ );
1298
1308
  return await syncFolder(resolvedPath, {
1299
1309
  apiUrl: options.apiUrl,
1300
1310
  token: options.token,
@@ -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.4'; // x-release-please-version
1
+ export const VERSION = '0.39.6'; // x-release-please-version
package/version.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.39.4";
1
+ export declare const VERSION = "0.39.6";
2
2
  //# sourceMappingURL=version.d.mts.map
package/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.39.4";
1
+ export declare const VERSION = "0.39.6";
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.4'; // x-release-please-version
4
+ exports.VERSION = '0.39.6'; // x-release-please-version
5
5
  //# sourceMappingURL=version.js.map
package/version.mjs CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '0.39.4'; // x-release-please-version
1
+ export const VERSION = '0.39.6'; // x-release-please-version
2
2
  //# sourceMappingURL=version.mjs.map