@dnax/core 0.77.9 → 0.78.1

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/lib/media/sftp.ts CHANGED
@@ -1,116 +1,699 @@
1
- import { Client } from "ssh2";
2
- import type { SFTPWrapper } from "ssh2";
3
- import { consola } from "consola";
1
+ import { $ } from "bun";
2
+ import { join } from "path";
3
+ import { tmpdir } from "os";
4
4
  import path from "path";
5
5
  import chokidar from "chokidar";
6
6
  import fs from "fs-extra";
7
7
  import type { Collection } from "../../types";
8
+ import consola from "consola";
8
9
  const BASE_DIR = "/uploads/";
9
- type ConfigType = {
10
- host: string;
11
- port?: number;
12
- username: string;
13
- password: string;
14
- remoteDir: string;
15
- path?: string;
16
- ignorePatterns?: string[];
17
- //privateKeyPath?: string;
18
- };
19
- class FilesystemSftpAdapter {
20
- conn: Client;
21
- type: "sftp";
22
- config: ConfigType;
23
- constructor(
24
- config = {
25
- port: 22,
26
- } as ConfigType
27
- ) {
28
- this.config = config;
29
- this.type = "sftp";
30
- this.conn = new Client({
31
- captureRejections: true,
32
- //captureRejections: true,
33
- });
34
-
35
- this.connect()
36
- .then((e) => {
37
- e.conn.end();
38
- })
39
- .catch((err) => {
40
- consola.error(`SFTP: Failed to connect ${this.config.host}`.red);
41
- });
42
- // this.conn.
43
- return this;
44
- }
45
10
 
46
- async syncCollectionMedia(col: Collection) {
47
- if (!this.config?.remoteDir) this.config.remoteDir = "/home/" + col?.slug;
48
-
49
- let visibility = col?.media?.visibility || "public";
50
- let dirToWatch = path.join(process.cwd(), BASE_DIR, col?.slug, visibility);
51
- dirToWatch = path.resolve(dirToWatch);
52
- const watcher = chokidar.watch(dirToWatch, {
53
- persistent: true,
54
- ignoreInitial: true,
55
- ignored: this.config.ignorePatterns || [],
56
- });
57
-
58
- watcher.on("all", (event, filePath) => {
59
- if (event == "change" || event == "add") {
60
- //consola.info(`SFTP: ${event} ${filePath}`);
61
- let remotePath = path.join(
62
- this.config.remoteDir,
63
- path.basename(filePath)
11
+ // ============================================================================
12
+ // EXPECT DEPENDENCY CHECK
13
+ // ============================================================================
14
+
15
+ function getExpectInstallMessage(): string {
16
+ return [
17
+ "Linux (Debian/Ubuntu) : sudo apt-get install expect",
18
+ "Linux (RHEL/CentOS) : sudo yum install expect",
19
+ "Linux (Fedora) : sudo dnf install expect",
20
+ "macOS : brew install expect",
21
+ "Windows (Chocolatey) : choco install expect",
22
+ "Windows (autrement) : utilisez WSL ou Cygwin pour avoir expect.",
23
+ ].join("\n");
24
+ }
25
+
26
+ async function checkExpectInstalled(): Promise<void> {
27
+ try {
28
+ await $`expect -c "exit 0"`.quiet();
29
+ } catch {
30
+ const instructions = getExpectInstallMessage();
31
+ throw new Error(
32
+ `La commande "expect" n'est pas installée sur cette machine. Elle est requise pour l'authentification par mot de passe SFTP/SSH.\n\n` +
33
+ `Installation : ${instructions}`
64
34
  );
65
- this.put(filePath, remotePath);
66
- }
67
- });
68
- }
35
+ }
36
+ }
37
+
38
+ // ============================================================================
39
+ // TYPES & INTERFACES
40
+ // ============================================================================
41
+
42
+ interface SftpConfig {
43
+ host: string;
44
+ user: string;
45
+ username?: string;
46
+ port?: number;
47
+ password?: string;
48
+ identityFile?: string;
49
+ remoteDir?: string;
50
+ ignorePatterns?: string[];
51
+ }
52
+
53
+ interface FileInfo {
54
+ name: string;
55
+ size: number;
56
+ isDirectory: boolean;
57
+ modified: Date;
58
+ permissions: string;
59
+ }
60
+
61
+ interface UploadOptions {
62
+ onProgress?: (percent: number) => void;
63
+ overwrite?: boolean;
64
+ }
65
+
66
+ interface DownloadOptions {
67
+ onProgress?: (percent: number) => void;
68
+ }
69
+
70
+ // ============================================================================
71
+ // FILESYSTEM SFTP ADAPTER
72
+ // ============================================================================
73
+
74
+ class FilesystemSftpAdapter {
75
+ private config: SftpConfig;
76
+ private connected: boolean = false;
77
+ private remoteDir: string;
78
+ private ignorePatterns: string[];
79
+ type: "sftp";
80
+
81
+ constructor(config: SftpConfig) {
82
+ this.type = "sftp";
83
+ if (!config.username) config.username = "root";
84
+ config.user = config.username;
85
+ this.config = config;
86
+ this.remoteDir = config.remoteDir || "/";
87
+ this.ignorePatterns = config.ignorePatterns || [];
88
+ this.connect()
89
+ }
69
90
 
70
- connect(): Promise<{ sftp: SFTPWrapper; conn: Client }> {
71
- return new Promise((resolve, reject) => {
72
- this.conn
73
- .on("ready", () => {
74
- this.conn.sftp((err, sftp) => {
75
- if (err) {
76
- this.conn.end();
77
- return reject(err?.message);
91
+ // --------------------------------------------------------------------------
92
+ // PRIVATE HELPERS
93
+ // --------------------------------------------------------------------------
94
+
95
+ private get destination(): string {
96
+ return `${this.config.user}@${this.config.host}`;
97
+ }
98
+
99
+ private resolvePath(remotePath: string): string {
100
+ if (remotePath.startsWith("/")) {
101
+ return remotePath;
102
+ }
103
+ return join(this.remoteDir, remotePath).replace(/\\/g, "/");
104
+ }
105
+
106
+ private shouldIgnore(filePath: string): boolean {
107
+ if (this.ignorePatterns.length === 0) return false;
108
+
109
+ const fileName = filePath.split("/").pop() || filePath;
110
+
111
+ for (const pattern of this.ignorePatterns) {
112
+ // Support glob patterns: *, **, ?
113
+ const regexPattern = pattern
114
+ .replace(/\./g, "\\.")
115
+ .replace(/\*\*/g, "{{DOUBLE_STAR}}")
116
+ .replace(/\*/g, "[^/]*")
117
+ .replace(/{{DOUBLE_STAR}}/g, ".*")
118
+ .replace(/\?/g, ".");
119
+
120
+ const regex = new RegExp(`^${regexPattern}$`);
121
+
122
+ if (regex.test(fileName) || regex.test(filePath)) {
123
+ return true;
124
+ }
125
+ }
126
+
127
+ return false;
128
+ }
129
+
130
+ private async getLocalFiles(localDir: string, baseDir = ""): Promise<string[]> {
131
+ const files: string[] = [];
132
+ const entries = await $`ls -1 ${localDir}`.quiet().text();
133
+
134
+ for (const entry of entries.trim().split("\n").filter(Boolean)) {
135
+ const fullPath = join(localDir, entry);
136
+ const relativePath = baseDir ? `${baseDir}/${entry}` : entry;
137
+
138
+ if (this.shouldIgnore(relativePath) || this.shouldIgnore(entry)) {
139
+ continue;
140
+ }
141
+
142
+ const isDir = await $`test -d ${fullPath} && echo yes || echo no`.quiet().text();
143
+
144
+ if (isDir.trim() === "yes") {
145
+ const subFiles = await this.getLocalFiles(fullPath, relativePath);
146
+ files.push(...subFiles);
147
+ } else {
148
+ files.push(relativePath);
78
149
  }
79
- resolve({
80
- sftp: sftp,
81
- conn: this.conn,
82
- });
83
- });
84
- })
85
- .on("error", (err) => reject(err?.message))
86
- .connect(this.config);
87
- });
150
+ }
151
+
152
+ return files;
153
+ }
154
+
155
+ private get portArg(): string {
156
+ return this.config.port ? `-P ${this.config.port}` : "";
157
+ }
158
+
159
+ private get sshPortArg(): string {
160
+ return this.config.port ? `-p ${this.config.port}` : "";
161
+ }
162
+
163
+ private get identityArg(): string {
164
+ if (!this.config.identityFile) return "";
165
+ const keyPath = this.config.identityFile.replace("~", process.env.HOME || "");
166
+ return `-i ${keyPath}`;
167
+ }
168
+
169
+ private async execWithPassword(command: string): Promise<string> {
170
+ const { password } = this.config;
171
+
172
+ if (!password) {
173
+ const result = await $`sh -c ${command}`.quiet();
174
+ return result.text();
175
+ }
176
+
177
+ await checkExpectInstalled();
178
+
179
+ const expectScript = `
180
+ set timeout 30
181
+ spawn sh -c "${command.replace(/"/g, '\\"')}"
182
+ expect {
183
+ "password:" {
184
+ send "${password}\\r"
185
+ exp_continue
186
+ }
187
+ "Password:" {
188
+ send "${password}\\r"
189
+ exp_continue
88
190
  }
89
- async put(localPath: string, remotePath: string) {
90
- let { sftp, conn } = await this.connect();
91
- return new Promise(async (resolve, reject) => {
92
- sftp.fastPut(
93
- localPath,
94
- remotePath,
95
- {
96
-
97
- mode: 0o777,
98
- // concurrency
99
- },
100
- (err: any) => {
101
- conn.end();
102
- if (err) {
103
- console.error(err?.message || err);
104
- }
105
- if (err) {
106
- reject(err?.message);
107
- }
108
-
109
- resolve(true);
110
- }
111
- );
112
- });
191
+ "yes/no" {
192
+ send "yes\\r"
193
+ exp_continue
113
194
  }
195
+ eof
196
+ }
197
+ catch wait result
198
+ exit [lindex $result 3]
199
+ `;
200
+
201
+ const scriptFile = join(tmpdir(), `expect-${Date.now()}.exp`);
202
+ await Bun.write(scriptFile, expectScript);
203
+
204
+ try {
205
+ const result = await $`expect ${scriptFile}`.quiet();
206
+ return result.text();
207
+ } finally {
208
+ await $`rm -f ${scriptFile}`.quiet().nothrow();
209
+ }
210
+ }
211
+
212
+ private async sshExec(command: string): Promise<string> {
213
+ const cmd = `ssh ${this.identityArg} ${this.sshPortArg} -o StrictHostKeyChecking=accept-new ${this.destination} ${command}`;
214
+ return await this.execWithPassword(cmd);
215
+ }
216
+
217
+ private cleanOutput(output: string): string[] {
218
+ return output
219
+ .split("\n")
220
+ .filter((line) => {
221
+ const lower = line.toLowerCase();
222
+ return (
223
+ line.trim() &&
224
+ !lower.includes("password") &&
225
+ !lower.includes("spawn") &&
226
+ !lower.includes("expect")
227
+ );
228
+ })
229
+ .map((line) => line.trim());
230
+ }
231
+
232
+ // --------------------------------------------------------------------------
233
+ // CONNECTION
234
+ // --------------------------------------------------------------------------
235
+
236
+ async connect(): Promise<void> {
237
+ try {
238
+ // consola.info(`Connecting to ${this.destination}`.blue);
239
+ const cmd = `ssh ${this.identityArg} ${this.sshPortArg} -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 ${this.destination} echo connected`;
240
+ const output = await this.execWithPassword(cmd);
241
+
242
+ if (!output.includes("connected")) {
243
+ throw new Error("Connection verification failed");
244
+ }
245
+
246
+ this.connected = true;
247
+ consola.log(`✓ SFTP Connected to ${this.config.host}`.green);
248
+ } catch (error: any) {
249
+ // throw new Error(`Connexion échouée: ${error?.message}`);
250
+ consola.error(`Connexion échouée: ${error?.message}`);
251
+ }
252
+ }
253
+
254
+ async disconnect(): Promise<void> {
255
+ this.connected = false;
256
+ console.log(`✓ Déconnecté de ${this.config.host}`);
257
+ }
258
+
259
+ isConnected(): boolean {
260
+ return this.connected;
261
+ }
262
+
263
+ // --------------------------------------------------------------------------
264
+ // UPLOAD
265
+ // --------------------------------------------------------------------------
266
+
267
+ async syncCollectionMedia(col: Collection) {
268
+ if (!this.config?.remoteDir) this.config.remoteDir = "/home/" + col?.slug;
269
+
270
+ let visibility = col?.media?.visibility || "public";
271
+ let dirToWatch = path.join(process.cwd(), BASE_DIR, col?.slug, visibility);
272
+ dirToWatch = path.resolve(dirToWatch);
273
+ const watcher = chokidar.watch(dirToWatch, {
274
+ persistent: true,
275
+ ignoreInitial: true,
276
+ ignored: this.config.ignorePatterns || [],
277
+ });
278
+
279
+ watcher.on("all", (event, filePath) => {
280
+ // console.log(`SFTP: ${event} ${filePath}`);
281
+ if (event == "change" || event == "add") {
282
+ //consola.info(`SFTP: ${event} ${filePath}`);
283
+ let remotePath = path.join(
284
+ this.config.remoteDir || '/home/' + col?.slug,
285
+ path.basename(filePath)
286
+ );
287
+ this.upload(filePath, remotePath).catch(err => {
288
+ consola.error(`${err?.message}`.red);
289
+ });
290
+ }
291
+ });
292
+ }
293
+ async upload(
294
+ localPath: string,
295
+ remotePath: string,
296
+ options?: UploadOptions
297
+ ): Promise<void> {
298
+ const file = Bun.file(localPath);
299
+
300
+ if (!(await file.exists())) {
301
+ throw new Error(`Fichier local introuvable: ${localPath}`);
302
+ }
303
+
304
+ const fileName = localPath.split("/").pop() || localPath;
305
+ if (this.shouldIgnore(fileName) || this.shouldIgnore(localPath)) {
306
+ console.log(`⊘ Ignoré: ${localPath}`);
307
+ return;
308
+ }
309
+
310
+ const resolvedPath = this.resolvePath(remotePath);
311
+
312
+ if (!options?.overwrite) {
313
+ const exists = await this.exists(resolvedPath);
314
+ if (exists) {
315
+ throw new Error(`Fichier distant existe déjà: ${resolvedPath}`);
316
+ }
317
+ }
318
+
319
+ const cmd = `scp ${this.identityArg} ${this.portArg} -o StrictHostKeyChecking=accept-new ${localPath} ${this.destination}:${resolvedPath}`;
320
+
321
+ await this.execWithPassword(cmd);
322
+ console.log(`✓ Upload: ${localPath} → ${resolvedPath}`);
323
+ }
324
+
325
+ async uploadMultiple(
326
+ files: { local: string; remote: string }[],
327
+ options?: UploadOptions
328
+ ): Promise<void> {
329
+ const filteredFiles = files.filter((file) => {
330
+ const fileName = file.local.split("/").pop() || file.local;
331
+ return !this.shouldIgnore(fileName) && !this.shouldIgnore(file.local);
332
+ });
333
+
334
+ const total = filteredFiles.length;
335
+ let uploaded = 0;
336
+
337
+ for (let i = 0; i < total; i++) {
338
+ const file = filteredFiles[i];
339
+ await this.upload(file.local, file.remote, { ...options, overwrite: options?.overwrite });
340
+ uploaded++;
341
+
342
+ if (options?.onProgress) {
343
+ options.onProgress(Math.round((uploaded / total) * 100));
344
+ }
345
+ }
346
+
347
+ console.log(`✓ ${uploaded} fichiers uploadés`);
348
+ }
349
+
350
+ async uploadDirectory(
351
+ localDir: string,
352
+ remoteDir?: string,
353
+ options?: UploadOptions
354
+ ): Promise<void> {
355
+ const targetDir = remoteDir ? this.resolvePath(remoteDir) : this.remoteDir;
356
+
357
+ // Get all files respecting ignorePatterns
358
+ const localFiles = await this.getLocalFiles(localDir);
359
+
360
+ // Create remote directory structure
361
+ const dirs = new Set<string>();
362
+ for (const file of localFiles) {
363
+ const dir = file.split("/").slice(0, -1).join("/");
364
+ if (dir) dirs.add(dir);
365
+ }
366
+
367
+ // Create directories
368
+ for (const dir of Array.from(dirs).sort()) {
369
+ const remoteDirPath = `${targetDir}/${dir}`;
370
+ try {
371
+ await this.mkdir(remoteDirPath, true);
372
+ } catch {
373
+ // Directory might already exist
374
+ }
375
+ }
376
+
377
+ // Upload files
378
+ const total = localFiles.length;
379
+ let uploaded = 0;
380
+
381
+ for (const file of localFiles) {
382
+ const localPath = join(localDir, file);
383
+ const remotePath = `${targetDir}/${file}`;
384
+
385
+ await this.upload(localPath, remotePath, { overwrite: true });
386
+ uploaded++;
387
+
388
+ if (options?.onProgress) {
389
+ options.onProgress(Math.round((uploaded / total) * 100));
390
+ }
391
+ }
392
+
393
+ console.log(`✓ Dossier uploadé: ${localDir} → ${targetDir} (${uploaded} fichiers)`);
394
+ }
395
+
396
+ // --------------------------------------------------------------------------
397
+ // DOWNLOAD
398
+ // --------------------------------------------------------------------------
399
+
400
+ async download(
401
+ remotePath: string,
402
+ localPath: string,
403
+ options?: DownloadOptions
404
+ ): Promise<void> {
405
+ const resolvedPath = this.resolvePath(remotePath);
406
+ const cmd = `scp ${this.identityArg} ${this.portArg} -o StrictHostKeyChecking=accept-new ${this.destination}:${resolvedPath} ${localPath}`;
407
+
408
+ await this.execWithPassword(cmd);
409
+ console.log(`✓ Download: ${resolvedPath} → ${localPath}`);
410
+ }
411
+
412
+ async downloadMultiple(
413
+ files: { remote: string; local: string }[],
414
+ options?: DownloadOptions
415
+ ): Promise<void> {
416
+ const total = files.length;
417
+
418
+ for (let i = 0; i < total; i++) {
419
+ const file = files[i];
420
+ await this.download(file.remote, file.local);
421
+
422
+ if (options?.onProgress) {
423
+ options.onProgress(Math.round(((i + 1) / total) * 100));
424
+ }
425
+ }
426
+
427
+ console.log(`✓ ${total} fichiers téléchargés`);
428
+ }
429
+
430
+ async downloadDirectory(
431
+ remoteDir: string,
432
+ localDir: string,
433
+ options?: DownloadOptions
434
+ ): Promise<void> {
435
+ const resolvedPath = this.resolvePath(remoteDir);
436
+ const cmd = `scp -r ${this.identityArg} ${this.portArg} -o StrictHostKeyChecking=accept-new ${this.destination}:${resolvedPath} ${localDir}`;
437
+
438
+ await this.execWithPassword(cmd);
439
+ console.log(`✓ Dossier téléchargé: ${resolvedPath} → ${localDir}`);
440
+ }
441
+
442
+ // --------------------------------------------------------------------------
443
+ // FILE OPERATIONS
444
+ // --------------------------------------------------------------------------
445
+
446
+ async list(remotePath?: string): Promise<string[]> {
447
+ const resolvedPath = remotePath ? this.resolvePath(remotePath) : this.remoteDir;
448
+ const output = await this.sshExec(`ls -1 "${resolvedPath}"`);
449
+ return this.cleanOutput(output);
450
+ }
451
+
452
+ async listDetailed(remotePath?: string): Promise<FileInfo[]> {
453
+ const resolvedPath = remotePath ? this.resolvePath(remotePath) : this.remoteDir;
454
+ const output = await this.sshExec(
455
+ `ls -la --time-style=+%s "${resolvedPath}" | tail -n +2`
456
+ );
457
+
458
+ const lines = this.cleanOutput(output);
459
+ const files: FileInfo[] = [];
460
+
461
+ for (const line of lines) {
462
+ const parts = line.split(/\s+/);
463
+ if (parts.length >= 7) {
464
+ const permissions = parts[0];
465
+ const size = parseInt(parts[4], 10);
466
+ const timestamp = parseInt(parts[5], 10);
467
+ const name = parts.slice(6).join(" ");
468
+
469
+ if (name && name !== "." && name !== "..") {
470
+ files.push({
471
+ name,
472
+ size,
473
+ isDirectory: permissions.startsWith("d"),
474
+ modified: new Date(timestamp * 1000),
475
+ permissions,
476
+ });
477
+ }
478
+ }
479
+ }
480
+
481
+ return files;
482
+ }
483
+
484
+ async exists(remotePath: string): Promise<boolean> {
485
+ try {
486
+ const resolvedPath = this.resolvePath(remotePath);
487
+ const output = await this.sshExec(
488
+ `test -e "${resolvedPath}" && echo "EXISTS" || echo "NOT_EXISTS"`
489
+ );
490
+ return output.includes("EXISTS");
491
+ } catch {
492
+ return false;
493
+ }
494
+ }
495
+
496
+ async isDirectory(remotePath: string): Promise<boolean> {
497
+ try {
498
+ const resolvedPath = this.resolvePath(remotePath);
499
+ const output = await this.sshExec(
500
+ `test -d "${resolvedPath}" && echo "YES" || echo "NO"`
501
+ );
502
+ return output.includes("YES");
503
+ } catch {
504
+ return false;
505
+ }
506
+ }
507
+
508
+ async isFile(remotePath: string): Promise<boolean> {
509
+ try {
510
+ const resolvedPath = this.resolvePath(remotePath);
511
+ const output = await this.sshExec(
512
+ `test -f "${resolvedPath}" && echo "YES" || echo "NO"`
513
+ );
514
+ return output.includes("YES");
515
+ } catch {
516
+ return false;
517
+ }
518
+ }
519
+
520
+ async stat(remotePath: string): Promise<FileInfo> {
521
+ const resolvedPath = this.resolvePath(remotePath);
522
+ const output = await this.sshExec(
523
+ `stat -c '%n %s %F %Y %A' "${resolvedPath}"`
524
+ );
525
+
526
+ const line = this.cleanOutput(output)[0];
527
+ const parts = line.split(" ");
528
+
529
+ return {
530
+ name: parts[0],
531
+ size: parseInt(parts[1], 10),
532
+ isDirectory: parts[2] === "directory",
533
+ modified: new Date(parseInt(parts[3], 10) * 1000),
534
+ permissions: parts[4],
535
+ };
536
+ }
537
+
538
+ async size(remotePath: string): Promise<number> {
539
+ const resolvedPath = this.resolvePath(remotePath);
540
+ const output = await this.sshExec(`stat -c '%s' "${resolvedPath}"`);
541
+ return parseInt(this.cleanOutput(output)[0], 10);
542
+ }
543
+
544
+ // --------------------------------------------------------------------------
545
+ // DIRECTORY OPERATIONS
546
+ // --------------------------------------------------------------------------
547
+
548
+ async mkdir(remotePath: string, recursive = false): Promise<void> {
549
+ const resolvedPath = this.resolvePath(remotePath);
550
+ const flag = recursive ? "-p" : "";
551
+ await this.sshExec(`mkdir ${flag} "${resolvedPath}"`);
552
+ console.log(`✓ Dossier créé: ${resolvedPath}`);
553
+ }
554
+
555
+ async rmdir(remotePath: string, recursive = false): Promise<void> {
556
+ const resolvedPath = this.resolvePath(remotePath);
557
+ const flag = recursive ? "-rf" : "-d";
558
+ await this.sshExec(`rm ${flag} "${resolvedPath}"`);
559
+ console.log(`✓ Dossier supprimé: ${resolvedPath}`);
560
+ }
561
+
562
+ // --------------------------------------------------------------------------
563
+ // FILE MANIPULATION
564
+ // --------------------------------------------------------------------------
565
+
566
+ async delete(remotePath: string): Promise<void> {
567
+ const resolvedPath = this.resolvePath(remotePath);
568
+ await this.sshExec(`rm -f "${resolvedPath}"`);
569
+ console.log(`✓ Fichier supprimé: ${resolvedPath}`);
570
+ }
571
+
572
+ async rename(oldPath: string, newPath: string): Promise<void> {
573
+ const resolvedOld = this.resolvePath(oldPath);
574
+ const resolvedNew = this.resolvePath(newPath);
575
+ await this.sshExec(`mv "${resolvedOld}" "${resolvedNew}"`);
576
+ console.log(`✓ Renommé: ${resolvedOld} → ${resolvedNew}`);
577
+ }
578
+
579
+ async copy(srcPath: string, destPath: string): Promise<void> {
580
+ const resolvedSrc = this.resolvePath(srcPath);
581
+ const resolvedDest = this.resolvePath(destPath);
582
+ await this.sshExec(`cp "${resolvedSrc}" "${resolvedDest}"`);
583
+ console.log(`✓ Copié: ${resolvedSrc} → ${resolvedDest}`);
584
+ }
585
+
586
+ async chmod(remotePath: string, mode: string | number): Promise<void> {
587
+ const resolvedPath = this.resolvePath(remotePath);
588
+ await this.sshExec(`chmod ${mode} "${resolvedPath}"`);
589
+ console.log(`✓ Permissions: ${resolvedPath} → ${mode}`);
590
+ }
591
+
592
+ async chown(remotePath: string, owner: string, group?: string): Promise<void> {
593
+ const resolvedPath = this.resolvePath(remotePath);
594
+ const ownerGroup = group ? `${owner}:${group}` : owner;
595
+ await this.sshExec(`chown ${ownerGroup} "${resolvedPath}"`);
596
+ console.log(`✓ Propriétaire: ${resolvedPath} → ${ownerGroup}`);
597
+ }
598
+
599
+ // --------------------------------------------------------------------------
600
+ // CONTENT OPERATIONS
601
+ // --------------------------------------------------------------------------
602
+
603
+ async readFile(remotePath: string): Promise<string> {
604
+ const resolvedPath = this.resolvePath(remotePath);
605
+ return await this.sshExec(`cat "${resolvedPath}"`);
606
+ }
607
+
608
+ async writeFile(remotePath: string, content: string): Promise<void> {
609
+ const resolvedPath = this.resolvePath(remotePath);
610
+ const tempFile = join(tmpdir(), `write-${Date.now()}`);
611
+ await Bun.write(tempFile, content);
612
+
613
+ try {
614
+ const cmd = `scp ${this.identityArg} ${this.portArg} -o StrictHostKeyChecking=accept-new ${tempFile} ${this.destination}:${resolvedPath}`;
615
+ await this.execWithPassword(cmd);
616
+ console.log(`✓ Fichier écrit: ${resolvedPath}`);
617
+ } finally {
618
+ await $`rm -f ${tempFile}`.quiet().nothrow();
619
+ }
620
+ }
621
+
622
+ async appendFile(remotePath: string, content: string): Promise<void> {
623
+ const resolvedPath = this.resolvePath(remotePath);
624
+ const escaped = content.replace(/'/g, "'\\''");
625
+ await this.sshExec(`echo '${escaped}' >> "${resolvedPath}"`);
626
+ }
627
+
628
+ // --------------------------------------------------------------------------
629
+ // UTILITY
630
+ // --------------------------------------------------------------------------
631
+
632
+ async exec(command: string): Promise<string> {
633
+ return await this.sshExec(command);
634
+ }
635
+
636
+ async df(remotePath?: string): Promise<{ total: number; used: number; available: number }> {
637
+ const resolvedPath = remotePath ? this.resolvePath(remotePath) : this.remoteDir;
638
+ const output = await this.sshExec(`df -B1 "${resolvedPath}" | tail -1`);
639
+ const parts = this.cleanOutput(output)[0].split(/\s+/);
640
+
641
+ return {
642
+ total: parseInt(parts[1], 10),
643
+ used: parseInt(parts[2], 10),
644
+ available: parseInt(parts[3], 10),
645
+ };
646
+ }
647
+
648
+ async whoami(): Promise<string> {
649
+ const output = await this.sshExec("whoami");
650
+ return this.cleanOutput(output)[0];
651
+ }
652
+
653
+ async pwd(): Promise<string> {
654
+ const output = await this.sshExec("pwd");
655
+ return this.cleanOutput(output)[0];
656
+ }
657
+
658
+ // --------------------------------------------------------------------------
659
+ // GETTERS & SETTERS
660
+ // --------------------------------------------------------------------------
661
+
662
+ getRemoteDir(): string {
663
+ return this.remoteDir;
664
+ }
665
+
666
+ setRemoteDir(dir: string): void {
667
+ this.remoteDir = dir;
668
+ }
669
+
670
+ getIgnorePatterns(): string[] {
671
+ return [...this.ignorePatterns];
672
+ }
673
+
674
+ setIgnorePatterns(patterns: string[]): void {
675
+ this.ignorePatterns = patterns;
676
+ }
677
+
678
+ addIgnorePattern(pattern: string): void {
679
+ if (!this.ignorePatterns.includes(pattern)) {
680
+ this.ignorePatterns.push(pattern);
681
+ }
682
+ }
683
+
684
+ removeIgnorePattern(pattern: string): void {
685
+ this.ignorePatterns = this.ignorePatterns.filter((p) => p !== pattern);
686
+ }
114
687
  }
115
688
 
116
- export { FilesystemSftpAdapter };
689
+ // ============================================================================
690
+ // EXPORTS
691
+ // ============================================================================
692
+
693
+ export {
694
+ FilesystemSftpAdapter,
695
+ type SftpConfig,
696
+ type FileInfo,
697
+ type UploadOptions,
698
+ type DownloadOptions,
699
+ };