appium-ios-remotexpc 0.19.0 → 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 CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.20.0](https://github.com/appium/appium-ios-remotexpc/compare/v0.19.0...v0.20.0) (2025-12-19)
2
+
3
+ ### Features
4
+
5
+ * **afc:** add `recursive` option to pull, `mkdir` method and fix race condition in `pull` ([#113](https://github.com/appium/appium-ios-remotexpc/issues/113)) ([9a7d61f](https://github.com/appium/appium-ios-remotexpc/commit/9a7d61fcb0a47e5e1e4fbacf2cdc9e76d922e09f))
6
+
1
7
  ## [0.19.0](https://github.com/appium/appium-ios-remotexpc/compare/v0.18.1...v0.19.0) (2025-12-17)
2
8
 
3
9
  ### Features
@@ -1,6 +1,33 @@
1
1
  import { Readable, Writable } from 'node:stream';
2
2
  import { AFC_FOPEN_TEXTUAL_MODES } from './constants.js';
3
3
  import { AfcFileMode } from './enums.js';
4
+ /**
5
+ * Callback invoked for each file successfully pulled from the device.
6
+ *
7
+ * @param remotePath - The remote file path on the device
8
+ * @param localPath - The local file path where it was saved
9
+ *
10
+ * @remarks
11
+ * If the callback throws an error, the pull operation will be aborted immediately.
12
+ */
13
+ export type PullRecursiveCallback = (remotePath: string, localPath: string) => unknown | Promise<unknown>;
14
+ /** Options for the pull method. */
15
+ export interface PullOptions {
16
+ /**
17
+ * If true, recursively pull directories.
18
+ * @default false
19
+ */
20
+ recursive?: boolean;
21
+ /** Glob pattern to filter files (e.g., '*.txt', '**\/*.log'). */
22
+ match?: string;
23
+ /**
24
+ * If false, throws error when local file exists.
25
+ * @default true
26
+ */
27
+ overwrite?: boolean;
28
+ /** Callback invoked for each pulled file. */
29
+ callback?: PullRecursiveCallback;
30
+ }
4
31
  export interface StatInfo {
5
32
  st_ifmt: AfcFileMode;
6
33
  st_size: bigint;
@@ -39,7 +66,30 @@ export declare class AfcService {
39
66
  setFileContents(filePath: string, data: Buffer): Promise<void>;
40
67
  readToStream(filePath: string): Promise<Readable>;
41
68
  writeFromStream(filePath: string, stream: Readable): Promise<void>;
42
- pull(remoteSrc: string, localDst: string): Promise<void>;
69
+ /**
70
+ * Pull file(s) or directory from the device to the local filesystem.
71
+ *
72
+ * @param remoteSrc - Remote path on the device (file or directory)
73
+ * @param localDst - Local destination path
74
+ * @param options - Optional configuration
75
+ *
76
+ * @throws {Error} If the remote source path does not exist
77
+ * @throws {Error} If overwrite is false and local file already exists
78
+ *
79
+ * @remarks
80
+ * When pulling a directory with `recursive: true`, the directory itself will be created
81
+ * inside the destination. For example, pulling `/Downloads` to `/tmp` will create `/tmp/Downloads`.
82
+ */
83
+ pull(remoteSrc: string, localDst: string, options?: PullOptions): Promise<void>;
84
+ /**
85
+ * Create a directory on the device.
86
+ *
87
+ * Creates parent directories automatically and is idempotent (no error if the directory exists).
88
+ *
89
+ * @param dirPath - Path of the directory to create.
90
+ * @returns A promise that resolves when the directory has been created.
91
+ */
92
+ mkdir(dirPath: string): Promise<void>;
43
93
  rmSingle(filePath: string, force?: boolean): Promise<boolean>;
44
94
  rm(filePath: string, force?: boolean): Promise<string[]>;
45
95
  rename(src: string, dst: string): Promise<void>;
@@ -53,6 +103,29 @@ export declare class AfcService {
53
103
  * Close the underlying socket
54
104
  */
55
105
  close(): void;
106
+ /**
107
+ * Private primitive to pull a single file from device to local filesystem.
108
+ *
109
+ * @param remoteSrc - Remote file path on the device (must be a file)
110
+ * @param localDst - Local destination file path
111
+ */
112
+ private _pullFile;
113
+ /**
114
+ * Recursively pull directory contents from device to local filesystem.
115
+ *
116
+ * @remarks
117
+ * This method is intended for directories only. Caller must validate that remoteSrcDir
118
+ * is a directory before invoking.
119
+ */
120
+ private _pullRecursiveInternal;
121
+ /**
122
+ * Helper to check if a local filesystem path exists and is a directory.
123
+ */
124
+ private _isLocalDirectory;
125
+ /**
126
+ * Helper to check if a local path (file or directory) exists.
127
+ */
128
+ private _localPathExists;
56
129
  /**
57
130
  * Connect to RSD port and perform RSDCheckin.
58
131
  * Keeps the underlying socket for raw AFC I/O.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/services/ios/afc/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../../src/services/ios/afc/index.ts"],"names":[],"mappings":"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"}
@@ -1,10 +1,12 @@
1
+ import { minimatch } from 'minimatch';
1
2
  import fs from 'node:fs';
3
+ import fsp from 'node:fs/promises';
2
4
  import net from 'node:net';
3
5
  import path from 'node:path';
4
6
  import { Readable, Writable } from 'node:stream';
5
7
  import { pipeline } from 'node:stream/promises';
6
8
  import { getLogger } from '../../../lib/logger.js';
7
- import { buildClosePayload, buildFopenPayload, buildReadPayload, buildRemovePayload, buildRenamePayload, buildStatPayload, nanosecondsToMilliseconds, nextReadChunkSize, parseCStringArray, parseKeyValueNullList, readAfcResponse, rsdHandshakeForRawService, sendAfcPacket, writeUInt64LE, } from './codec.js';
9
+ import { buildClosePayload, buildFopenPayload, buildMkdirPayload, buildReadPayload, buildRemovePayload, buildRenamePayload, buildStatPayload, nanosecondsToMilliseconds, nextReadChunkSize, parseCStringArray, parseKeyValueNullList, readAfcResponse, rsdHandshakeForRawService, sendAfcPacket, writeUInt64LE, } from './codec.js';
8
10
  import { AFC_FOPEN_TEXTUAL_MODES, AFC_WRITE_THIS_LENGTH } from './constants.js';
9
11
  import { AfcError, AfcFileMode, AfcOpcode } from './enums.js';
10
12
  import { createAfcReadStream, createAfcWriteStream } from './stream-utils.js';
@@ -200,12 +202,70 @@ export class AfcService {
200
202
  await this.fclose(handle);
201
203
  }
202
204
  }
203
- async pull(remoteSrc, localDst) {
204
- log.debug(`Pulling file from '${remoteSrc}' to '${localDst}'`);
205
- const stream = await this.readToStream(remoteSrc);
206
- const writeStream = fs.createWriteStream(localDst);
207
- await pipeline(stream, writeStream);
208
- log.debug(`Successfully pulled file to '${localDst}'`);
205
+ /**
206
+ * Pull file(s) or directory from the device to the local filesystem.
207
+ *
208
+ * @param remoteSrc - Remote path on the device (file or directory)
209
+ * @param localDst - Local destination path
210
+ * @param options - Optional configuration
211
+ *
212
+ * @throws {Error} If the remote source path does not exist
213
+ * @throws {Error} If overwrite is false and local file already exists
214
+ *
215
+ * @remarks
216
+ * When pulling a directory with `recursive: true`, the directory itself will be created
217
+ * inside the destination. For example, pulling `/Downloads` to `/tmp` will create `/tmp/Downloads`.
218
+ */
219
+ async pull(remoteSrc, localDst, options) {
220
+ const { recursive = false, match, overwrite = true, callback, } = options ?? {};
221
+ if (!(await this.exists(remoteSrc))) {
222
+ throw new Error(`Remote path does not exist: ${remoteSrc}`);
223
+ }
224
+ const pullSingleFile = async (remoteFilePath, localFilePath) => {
225
+ log.debug(`Pulling file from '${remoteFilePath}' to '${localFilePath}'`);
226
+ if (!overwrite && (await this._localPathExists(localFilePath))) {
227
+ throw new Error(`Local file already exists: ${localFilePath}`);
228
+ }
229
+ await this._pullFile(remoteFilePath, localFilePath);
230
+ if (callback) {
231
+ await callback(remoteFilePath, localFilePath);
232
+ }
233
+ };
234
+ const isDir = await this.isdir(remoteSrc);
235
+ if (!isDir) {
236
+ const baseName = path.posix.basename(remoteSrc);
237
+ if (match && !minimatch(baseName, match)) {
238
+ return;
239
+ }
240
+ const localDstIsDirectory = await this._isLocalDirectory(localDst);
241
+ const targetPath = localDstIsDirectory
242
+ ? path.join(localDst, baseName)
243
+ : localDst;
244
+ await pullSingleFile(remoteSrc, targetPath);
245
+ return;
246
+ }
247
+ // Source is a directory, recursive option required
248
+ if (!recursive) {
249
+ throw new Error(`Cannot pull directory '${remoteSrc}' without recursive option. Set recursive: true to pull directories.`);
250
+ }
251
+ log.debug(`Starting recursive pull from '${remoteSrc}' to '${localDst}'`);
252
+ await this._pullRecursiveInternal(remoteSrc, localDst, {
253
+ match,
254
+ overwrite,
255
+ callback,
256
+ });
257
+ }
258
+ /**
259
+ * Create a directory on the device.
260
+ *
261
+ * Creates parent directories automatically and is idempotent (no error if the directory exists).
262
+ *
263
+ * @param dirPath - Path of the directory to create.
264
+ * @returns A promise that resolves when the directory has been created.
265
+ */
266
+ async mkdir(dirPath) {
267
+ await this._doOperation(AfcOpcode.MAKE_DIR, buildMkdirPayload(dirPath));
268
+ log.debug(`Successfully created directory: ${dirPath}`);
209
269
  }
210
270
  async rmSingle(filePath, force = false) {
211
271
  log.debug(`Removing single path: ${filePath} (force: ${force})`);
@@ -315,6 +375,112 @@ export class AfcService {
315
375
  }
316
376
  this.socket = null;
317
377
  }
378
+ /**
379
+ * Private primitive to pull a single file from device to local filesystem.
380
+ *
381
+ * @param remoteSrc - Remote file path on the device (must be a file)
382
+ * @param localDst - Local destination file path
383
+ */
384
+ async _pullFile(remoteSrc, localDst) {
385
+ log.debug(`Pulling file from '${remoteSrc}' to '${localDst}'`);
386
+ const resolved = await this._resolvePath(remoteSrc);
387
+ const st = await this.stat(resolved);
388
+ if (st.st_ifmt !== AfcFileMode.S_IFREG) {
389
+ throw new Error(`'${resolved}' isn't a regular file`);
390
+ }
391
+ const handle = await this.fopen(resolved, 'r');
392
+ try {
393
+ const stream = this.createReadStream(handle, st.st_size);
394
+ const writeStream = fs.createWriteStream(localDst);
395
+ await pipeline(stream, writeStream);
396
+ log.debug(`Successfully pulled file to '${localDst}' (${st.st_size} bytes)`);
397
+ }
398
+ finally {
399
+ await this.fclose(handle);
400
+ }
401
+ }
402
+ /**
403
+ * Recursively pull directory contents from device to local filesystem.
404
+ *
405
+ * @remarks
406
+ * This method is intended for directories only. Caller must validate that remoteSrcDir
407
+ * is a directory before invoking.
408
+ */
409
+ async _pullRecursiveInternal(remoteSrcDir, localDstDir, options, relativePath = '') {
410
+ const { match, overwrite = true, callback } = options ?? {};
411
+ let localDirPath;
412
+ if (!relativePath) {
413
+ const localDstIsDirectory = await this._isLocalDirectory(localDstDir);
414
+ if (!localDstIsDirectory) {
415
+ const stat = await fsp.stat(localDstDir).catch((err) => {
416
+ if (err.code === 'ENOENT') {
417
+ return null;
418
+ }
419
+ throw err;
420
+ });
421
+ if (stat?.isFile()) {
422
+ throw new Error(`Local destination exists and is a file, not a directory: ${localDstDir}`);
423
+ }
424
+ }
425
+ const baseName = path.posix.basename(remoteSrcDir);
426
+ localDirPath = localDstIsDirectory
427
+ ? path.join(localDstDir, baseName)
428
+ : localDstDir;
429
+ }
430
+ else {
431
+ localDirPath = localDstDir;
432
+ }
433
+ await fsp.mkdir(localDirPath, { recursive: true });
434
+ for (const entry of await this.listdir(remoteSrcDir)) {
435
+ const entryPath = path.posix.join(remoteSrcDir, entry);
436
+ const entryRelativePath = relativePath
437
+ ? path.posix.join(relativePath, entry)
438
+ : entry;
439
+ if (await this.isdir(entryPath)) {
440
+ await this._pullRecursiveInternal(entryPath, path.join(localDirPath, entry), options, entryRelativePath);
441
+ }
442
+ else {
443
+ if (match && !minimatch(entryRelativePath, match)) {
444
+ continue;
445
+ }
446
+ const targetPath = path.join(localDirPath, entry);
447
+ if (!overwrite && (await this._localPathExists(targetPath))) {
448
+ throw new Error(`Local file already exists: ${targetPath}`);
449
+ }
450
+ await this._pullFile(entryPath, targetPath);
451
+ if (callback) {
452
+ await callback(entryPath, targetPath);
453
+ }
454
+ }
455
+ }
456
+ }
457
+ /**
458
+ * Helper to check if a local filesystem path exists and is a directory.
459
+ */
460
+ async _isLocalDirectory(localPath) {
461
+ try {
462
+ const stats = await fsp.stat(localPath);
463
+ return stats.isDirectory();
464
+ }
465
+ catch {
466
+ return false;
467
+ }
468
+ }
469
+ /**
470
+ * Helper to check if a local path (file or directory) exists.
471
+ */
472
+ async _localPathExists(localPath) {
473
+ try {
474
+ await fsp.access(localPath, fsp.constants.F_OK);
475
+ return true;
476
+ }
477
+ catch (err) {
478
+ if (err.code === 'ENOENT') {
479
+ return false;
480
+ }
481
+ throw err;
482
+ }
483
+ }
318
484
  /**
319
485
  * Connect to RSD port and perform RSDCheckin.
320
486
  * Keeps the underlying socket for raw AFC I/O.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appium-ios-remotexpc",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "main": "build/src/index.js",
5
5
  "types": "build/src/index.d.ts",
6
6
  "type": "module",
@@ -91,6 +91,7 @@
91
91
  "@xmldom/xmldom": "^0.9.8",
92
92
  "appium-ios-tuntap": "^0.x",
93
93
  "axios": "^1.12.0",
94
+ "minimatch": "^10.1.1",
94
95
  "npm-run-all2": "^8.0.4"
95
96
  },
96
97
  "files": [
@@ -1,4 +1,6 @@
1
+ import { minimatch } from 'minimatch';
1
2
  import fs from 'node:fs';
3
+ import fsp from 'node:fs/promises';
2
4
  import net from 'node:net';
3
5
  import path from 'node:path';
4
6
  import { Readable, Writable } from 'node:stream';
@@ -8,6 +10,7 @@ import { getLogger } from '../../../lib/logger.js';
8
10
  import {
9
11
  buildClosePayload,
10
12
  buildFopenPayload,
13
+ buildMkdirPayload,
11
14
  buildReadPayload,
12
15
  buildRemovePayload,
13
16
  buildRenamePayload,
@@ -29,6 +32,38 @@ const log = getLogger('AfcService');
29
32
 
30
33
  const NON_LISTABLE_ENTRIES = ['', '.', '..'];
31
34
 
35
+ /**
36
+ * Callback invoked for each file successfully pulled from the device.
37
+ *
38
+ * @param remotePath - The remote file path on the device
39
+ * @param localPath - The local file path where it was saved
40
+ *
41
+ * @remarks
42
+ * If the callback throws an error, the pull operation will be aborted immediately.
43
+ */
44
+ export type PullRecursiveCallback = (
45
+ remotePath: string,
46
+ localPath: string,
47
+ ) => unknown | Promise<unknown>;
48
+
49
+ /** Options for the pull method. */
50
+ export interface PullOptions {
51
+ /**
52
+ * If true, recursively pull directories.
53
+ * @default false
54
+ */
55
+ recursive?: boolean;
56
+ /** Glob pattern to filter files (e.g., '*.txt', '**\/*.log'). */
57
+ match?: string;
58
+ /**
59
+ * If false, throws error when local file exists.
60
+ * @default true
61
+ */
62
+ overwrite?: boolean;
63
+ /** Callback invoked for each pulled file. */
64
+ callback?: PullRecursiveCallback;
65
+ }
66
+
32
67
  export interface StatInfo {
33
68
  st_ifmt: AfcFileMode;
34
69
  st_size: bigint;
@@ -288,12 +323,97 @@ export class AfcService {
288
323
  }
289
324
  }
290
325
 
291
- async pull(remoteSrc: string, localDst: string): Promise<void> {
292
- log.debug(`Pulling file from '${remoteSrc}' to '${localDst}'`);
293
- const stream = await this.readToStream(remoteSrc);
294
- const writeStream = fs.createWriteStream(localDst);
295
- await pipeline(stream, writeStream);
296
- log.debug(`Successfully pulled file to '${localDst}'`);
326
+ /**
327
+ * Pull file(s) or directory from the device to the local filesystem.
328
+ *
329
+ * @param remoteSrc - Remote path on the device (file or directory)
330
+ * @param localDst - Local destination path
331
+ * @param options - Optional configuration
332
+ *
333
+ * @throws {Error} If the remote source path does not exist
334
+ * @throws {Error} If overwrite is false and local file already exists
335
+ *
336
+ * @remarks
337
+ * When pulling a directory with `recursive: true`, the directory itself will be created
338
+ * inside the destination. For example, pulling `/Downloads` to `/tmp` will create `/tmp/Downloads`.
339
+ */
340
+ async pull(
341
+ remoteSrc: string,
342
+ localDst: string,
343
+ options?: PullOptions,
344
+ ): Promise<void> {
345
+ const {
346
+ recursive = false,
347
+ match,
348
+ overwrite = true,
349
+ callback,
350
+ } = options ?? {};
351
+
352
+ if (!(await this.exists(remoteSrc))) {
353
+ throw new Error(`Remote path does not exist: ${remoteSrc}`);
354
+ }
355
+
356
+ const pullSingleFile = async (
357
+ remoteFilePath: string,
358
+ localFilePath: string,
359
+ ): Promise<void> => {
360
+ log.debug(`Pulling file from '${remoteFilePath}' to '${localFilePath}'`);
361
+
362
+ if (!overwrite && (await this._localPathExists(localFilePath))) {
363
+ throw new Error(`Local file already exists: ${localFilePath}`);
364
+ }
365
+
366
+ await this._pullFile(remoteFilePath, localFilePath);
367
+
368
+ if (callback) {
369
+ await callback(remoteFilePath, localFilePath);
370
+ }
371
+ };
372
+
373
+ const isDir = await this.isdir(remoteSrc);
374
+
375
+ if (!isDir) {
376
+ const baseName = path.posix.basename(remoteSrc);
377
+
378
+ if (match && !minimatch(baseName, match)) {
379
+ return;
380
+ }
381
+
382
+ const localDstIsDirectory = await this._isLocalDirectory(localDst);
383
+ const targetPath = localDstIsDirectory
384
+ ? path.join(localDst, baseName)
385
+ : localDst;
386
+
387
+ await pullSingleFile(remoteSrc, targetPath);
388
+ return;
389
+ }
390
+
391
+ // Source is a directory, recursive option required
392
+ if (!recursive) {
393
+ throw new Error(
394
+ `Cannot pull directory '${remoteSrc}' without recursive option. Set recursive: true to pull directories.`,
395
+ );
396
+ }
397
+
398
+ log.debug(`Starting recursive pull from '${remoteSrc}' to '${localDst}'`);
399
+ await this._pullRecursiveInternal(remoteSrc, localDst, {
400
+ match,
401
+ overwrite,
402
+ callback,
403
+ });
404
+ }
405
+
406
+ /**
407
+ * Create a directory on the device.
408
+ *
409
+ * Creates parent directories automatically and is idempotent (no error if the directory exists).
410
+ *
411
+ * @param dirPath - Path of the directory to create.
412
+ * @returns A promise that resolves when the directory has been created.
413
+ */
414
+ async mkdir(dirPath: string): Promise<void> {
415
+ await this._doOperation(AfcOpcode.MAKE_DIR, buildMkdirPayload(dirPath));
416
+ log.debug(`Successfully created directory: ${dirPath}`);
297
417
  }
298
418
 
299
419
  async rmSingle(filePath: string, force = false): Promise<boolean> {
@@ -418,6 +538,137 @@ export class AfcService {
418
538
  this.socket = null;
419
539
  }
420
540
 
541
+ /**
542
+ * Private primitive to pull a single file from device to local filesystem.
543
+ *
544
+ * @param remoteSrc - Remote file path on the device (must be a file)
545
+ * @param localDst - Local destination file path
546
+ */
547
+ private async _pullFile(remoteSrc: string, localDst: string): Promise<void> {
548
+ log.debug(`Pulling file from '${remoteSrc}' to '${localDst}'`);
549
+
550
+ const resolved = await this._resolvePath(remoteSrc);
551
+ const st = await this.stat(resolved);
552
+
553
+ if (st.st_ifmt !== AfcFileMode.S_IFREG) {
554
+ throw new Error(`'${resolved}' isn't a regular file`);
555
+ }
556
+
557
+ const handle = await this.fopen(resolved, 'r');
558
+ try {
559
+ const stream = this.createReadStream(handle, st.st_size);
560
+ const writeStream = fs.createWriteStream(localDst);
561
+ await pipeline(stream, writeStream);
562
+ log.debug(
563
+ `Successfully pulled file to '${localDst}' (${st.st_size} bytes)`,
564
+ );
565
+ } finally {
566
+ await this.fclose(handle);
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Recursively pull directory contents from device to local filesystem.
572
+ *
573
+ * @remarks
574
+ * This method is intended for directories only. Caller must validate that remoteSrcDir
575
+ * is a directory before invoking.
576
+ */
577
+ private async _pullRecursiveInternal(
578
+ remoteSrcDir: string,
579
+ localDstDir: string,
580
+ options?: Omit<PullOptions, 'recursive'>,
581
+ relativePath = '',
582
+ ): Promise<void> {
583
+ const { match, overwrite = true, callback } = options ?? {};
584
+
585
+ let localDirPath: string;
586
+ if (!relativePath) {
587
+ const localDstIsDirectory = await this._isLocalDirectory(localDstDir);
588
+
589
+ if (!localDstIsDirectory) {
590
+ const stat = await fsp.stat(localDstDir).catch((err) => {
591
+ if (err.code === 'ENOENT') {
592
+ return null;
593
+ }
594
+ throw err;
595
+ });
596
+ if (stat?.isFile()) {
597
+ throw new Error(
598
+ `Local destination exists and is a file, not a directory: ${localDstDir}`,
599
+ );
600
+ }
601
+ }
602
+
603
+ const baseName = path.posix.basename(remoteSrcDir);
604
+ localDirPath = localDstIsDirectory
605
+ ? path.join(localDstDir, baseName)
606
+ : localDstDir;
607
+ } else {
608
+ localDirPath = localDstDir;
609
+ }
610
+
611
+ await fsp.mkdir(localDirPath, { recursive: true });
612
+
613
+ for (const entry of await this.listdir(remoteSrcDir)) {
614
+ const entryPath = path.posix.join(remoteSrcDir, entry);
615
+ const entryRelativePath = relativePath
616
+ ? path.posix.join(relativePath, entry)
617
+ : entry;
618
+
619
+ if (await this.isdir(entryPath)) {
620
+ await this._pullRecursiveInternal(
621
+ entryPath,
622
+ path.join(localDirPath, entry),
623
+ options,
624
+ entryRelativePath,
625
+ );
626
+ } else {
627
+ if (match && !minimatch(entryRelativePath, match)) {
628
+ continue;
629
+ }
630
+
631
+ const targetPath = path.join(localDirPath, entry);
632
+ if (!overwrite && (await this._localPathExists(targetPath))) {
633
+ throw new Error(`Local file already exists: ${targetPath}`);
634
+ }
635
+
636
+ await this._pullFile(entryPath, targetPath);
637
+
638
+ if (callback) {
639
+ await callback(entryPath, targetPath);
640
+ }
641
+ }
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Helper to check if a local filesystem path exists and is a directory.
647
+ */
648
+ private async _isLocalDirectory(localPath: string): Promise<boolean> {
649
+ try {
650
+ const stats = await fsp.stat(localPath);
651
+ return stats.isDirectory();
652
+ } catch {
653
+ return false;
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Helper to check if a local path (file or directory) exists.
659
+ */
660
+ private async _localPathExists(localPath: string): Promise<boolean> {
661
+ try {
662
+ await fsp.access(localPath, fsp.constants.F_OK);
663
+ return true;
664
+ } catch (err: any) {
665
+ if (err.code === 'ENOENT') {
666
+ return false;
667
+ }
668
+ throw err;
669
+ }
670
+ }
671
+
421
672
  /**
422
673
  * Connect to RSD port and perform RSDCheckin.
423
674
  * Keeps the underlying socket for raw AFC I/O.