ai-remote 0.4.13 → 0.4.15

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.
@@ -0,0 +1,718 @@
1
+ import {
2
+ SshSession,
3
+ TcpTransport
4
+ } from "./cli-chunk-EYQCDSPT.mjs";
5
+ import {
6
+ SshReader,
7
+ SshWriter
8
+ } from "./cli-chunk-XT2FISR5.mjs";
9
+
10
+ // src/cli/copy.ts
11
+ import { open as openFile, mkdir, readdir, stat as statFile } from "node:fs/promises";
12
+ import { basename, dirname, join, resolve as resolveLocal } from "node:path";
13
+
14
+ // src/protocols/ssh/sftp.ts
15
+ var FXP = {
16
+ INIT: 1,
17
+ VERSION: 2,
18
+ OPEN: 3,
19
+ CLOSE: 4,
20
+ READ: 5,
21
+ WRITE: 6,
22
+ LSTAT: 7,
23
+ FSTAT: 8,
24
+ SETSTAT: 9,
25
+ OPENDIR: 11,
26
+ READDIR: 12,
27
+ REMOVE: 13,
28
+ MKDIR: 14,
29
+ RMDIR: 15,
30
+ REALPATH: 16,
31
+ STAT: 17,
32
+ RENAME: 18,
33
+ STATUS: 101,
34
+ HANDLE: 102,
35
+ DATA: 103,
36
+ NAME: 104,
37
+ ATTRS: 105
38
+ };
39
+ var OPEN_FLAGS = {
40
+ READ: 1,
41
+ WRITE: 2,
42
+ APPEND: 4,
43
+ CREATE: 8,
44
+ TRUNCATE: 16,
45
+ EXCLUSIVE: 32
46
+ };
47
+ var ATTR = {
48
+ SIZE: 1,
49
+ UIDGID: 2,
50
+ PERMISSIONS: 4,
51
+ ACMODTIME: 8,
52
+ EXTENDED: 2147483648
53
+ };
54
+ var STATUS = {
55
+ OK: 0,
56
+ EOF: 1,
57
+ NO_SUCH_FILE: 2,
58
+ PERMISSION_DENIED: 3,
59
+ FAILURE: 4,
60
+ BAD_MESSAGE: 5,
61
+ NO_CONNECTION: 6,
62
+ CONNECTION_LOST: 7,
63
+ OP_UNSUPPORTED: 8
64
+ };
65
+ var STATUS_TEXT = {
66
+ [STATUS.EOF]: "the end of the file",
67
+ [STATUS.NO_SUCH_FILE]: "no such file or directory",
68
+ [STATUS.PERMISSION_DENIED]: "permission denied",
69
+ [STATUS.FAILURE]: "the host refused the operation",
70
+ [STATUS.BAD_MESSAGE]: "the host could not parse the request",
71
+ [STATUS.NO_CONNECTION]: "no connection",
72
+ [STATUS.CONNECTION_LOST]: "the connection was lost",
73
+ [STATUS.OP_UNSUPPORTED]: "the host does not support that operation"
74
+ };
75
+ var SftpError = class extends Error {
76
+ code;
77
+ constructor(code, detail, operation) {
78
+ const meaning = STATUS_TEXT[code] ?? `SFTP status ${code}`;
79
+ super(detail && detail.toLowerCase() !== "failure" ? `${operation}: ${detail}` : `${operation}: ${meaning}`);
80
+ this.code = code;
81
+ this.name = "SftpError";
82
+ }
83
+ /** True when the thing simply is not there, which callers treat as a fact rather than a failure. */
84
+ get missing() {
85
+ return this.code === STATUS.NO_SUCH_FILE;
86
+ }
87
+ };
88
+ var S_IFMT = 61440;
89
+ var S_IFDIR = 16384;
90
+ var CHUNK = 32 * 1024;
91
+ function readAttrs(reader) {
92
+ const flags = reader.u32();
93
+ const size = flags & ATTR.SIZE ? reader.u64() : null;
94
+ if (flags & ATTR.UIDGID) {
95
+ reader.u32();
96
+ reader.u32();
97
+ }
98
+ const permissions = flags & ATTR.PERMISSIONS ? reader.u32() : null;
99
+ let mtime = null;
100
+ if (flags & ATTR.ACMODTIME) {
101
+ reader.u32();
102
+ mtime = reader.u32();
103
+ }
104
+ if (flags & ATTR.EXTENDED) {
105
+ const count = reader.u32();
106
+ for (let index = 0; index < count; index++) {
107
+ reader.stringBytes();
108
+ reader.stringBytes();
109
+ }
110
+ }
111
+ return {
112
+ size,
113
+ permissions,
114
+ mtime,
115
+ directory: permissions !== null && (permissions & S_IFMT) === S_IFDIR
116
+ };
117
+ }
118
+ function writeAttrs(writer, mode) {
119
+ if (mode === null) return writer.u32(0);
120
+ return writer.u32(ATTR.PERMISSIONS).u32(mode);
121
+ }
122
+ function expect(reply, kind, operation) {
123
+ if (reply.kind !== kind) throw new Error(`${operation}: the host answered with ${reply.kind}, not ${kind}`);
124
+ return reply;
125
+ }
126
+ var Sftp = class {
127
+ #write;
128
+ #nextId = 1;
129
+ #pending = /* @__PURE__ */ new Map();
130
+ /** Bytes of a packet that has arrived only partly. */
131
+ #buffer = new Uint8Array(0);
132
+ #closed = false;
133
+ version = 0;
134
+ constructor({ write }) {
135
+ this.#write = write;
136
+ }
137
+ /**
138
+ * Channel data, in whatever sizes it arrived in.
139
+ *
140
+ * SSH hands over what fitted in a packet, which has nothing to do with where
141
+ * SFTP messages begin and end -- a 32 KB read comes back as several channel
142
+ * packets, and two small replies can share one. So the length prefix is the
143
+ * only framing, and the tail of an incomplete message is held here.
144
+ */
145
+ receive(bytes) {
146
+ if (!bytes.length) return;
147
+ const joined = new Uint8Array(this.#buffer.length + bytes.length);
148
+ joined.set(this.#buffer);
149
+ joined.set(bytes, this.#buffer.length);
150
+ this.#buffer = joined;
151
+ for (; ; ) {
152
+ if (this.#buffer.length < 4) return;
153
+ const reader = new SshReader(this.#buffer);
154
+ const length = reader.u32();
155
+ if (this.#buffer.length < 4 + length) return;
156
+ const packet = this.#buffer.subarray(4, 4 + length);
157
+ this.#buffer = this.#buffer.subarray(4 + length);
158
+ try {
159
+ this.#dispatch(packet);
160
+ } catch (error) {
161
+ this.fail(error instanceof Error ? error.message : String(error));
162
+ return;
163
+ }
164
+ }
165
+ }
166
+ /** Every request still waiting gives up, because the channel has gone. */
167
+ fail(message) {
168
+ this.#closed = true;
169
+ const waiting = [...this.#pending.values()];
170
+ this.#pending.clear();
171
+ for (const request of waiting) request.reject(new Error(message));
172
+ }
173
+ #dispatch(packet) {
174
+ const reader = new SshReader(packet);
175
+ const type = reader.u8();
176
+ if (type === FXP.VERSION) {
177
+ this.version = reader.u32();
178
+ const waiter2 = this.#pending.get(0);
179
+ this.#pending.delete(0);
180
+ waiter2?.resolve({ kind: "version", version: this.version });
181
+ return;
182
+ }
183
+ const id = reader.u32();
184
+ const waiter = this.#pending.get(id);
185
+ if (!waiter) return;
186
+ this.#pending.delete(id);
187
+ switch (type) {
188
+ case FXP.STATUS: {
189
+ const code = reader.u32();
190
+ const detail = reader.remaining ? reader.string() : "";
191
+ if (code === STATUS.OK) waiter.resolve({ kind: "ok" });
192
+ else if (code === STATUS.EOF) waiter.resolve({ kind: "eof" });
193
+ else waiter.reject(new SftpError(code, detail, waiter.operation));
194
+ return;
195
+ }
196
+ case FXP.HANDLE:
197
+ waiter.resolve({ kind: "handle", handle: reader.stringBytes() });
198
+ return;
199
+ case FXP.DATA:
200
+ waiter.resolve({ kind: "data", data: reader.stringBytes() });
201
+ return;
202
+ case FXP.ATTRS:
203
+ waiter.resolve({ kind: "attrs", attrs: readAttrs(reader) });
204
+ return;
205
+ case FXP.NAME: {
206
+ const count = reader.u32();
207
+ const names = [];
208
+ for (let index = 0; index < count; index++) {
209
+ const name = reader.string();
210
+ reader.string();
211
+ names.push({ name, attrs: readAttrs(reader) });
212
+ }
213
+ waiter.resolve({ kind: "names", names });
214
+ return;
215
+ }
216
+ default:
217
+ waiter.reject(new Error(`${waiter.operation}: unexpected SFTP reply type ${type}`));
218
+ }
219
+ }
220
+ /** Send one request and wait for the reply that carries its id. */
221
+ #request(type, operation, build) {
222
+ if (this.#closed) return Promise.reject(new Error(`${operation}: the SFTP channel is closed.`));
223
+ const id = this.#nextId++;
224
+ const body = new SshWriter(256).u8(type).u32(id);
225
+ build(body);
226
+ return this.#send(id, operation, body.take());
227
+ }
228
+ #send(id, operation, packet) {
229
+ const framed = new SshWriter(packet.length + 4).u32(packet.length).raw(packet).take();
230
+ return new Promise((resolve, reject) => {
231
+ this.#pending.set(id, { resolve, reject, operation });
232
+ try {
233
+ this.#write(framed);
234
+ } catch (error) {
235
+ this.#pending.delete(id);
236
+ reject(error instanceof Error ? error : new Error(String(error)));
237
+ }
238
+ });
239
+ }
240
+ /**
241
+ * Agree a version. Nothing else can be sent before this.
242
+ *
243
+ * The reply carries no request id -- it is the one message in the protocol
244
+ * that does not -- so it is parked under id 0, which no request uses.
245
+ */
246
+ async handshake() {
247
+ const packet = new SshWriter(16).u8(FXP.INIT).u32(3).take();
248
+ const reply = await this.#send(0, "SFTP handshake", packet);
249
+ const { version } = expect(reply, "version", "SFTP handshake");
250
+ if (version < 3) {
251
+ throw new Error(`SFTP: the host offered version ${version}, and this client needs 3.`);
252
+ }
253
+ return version;
254
+ }
255
+ async open(path, flags, mode = null) {
256
+ const operation = `opening ${path}`;
257
+ const reply = await this.#request(FXP.OPEN, operation, (writer) => {
258
+ writeAttrs(writer.string(path).u32(flags), mode);
259
+ });
260
+ return expect(reply, "handle", operation).handle;
261
+ }
262
+ async close(handle) {
263
+ await this.#request(FXP.CLOSE, "closing a file", (writer) => {
264
+ writer.string(handle);
265
+ });
266
+ }
267
+ /** Bytes at an offset, or null at the end of the file. */
268
+ async read(handle, offset, length) {
269
+ const reply = await this.#request(FXP.READ, "reading", (writer) => {
270
+ writer.string(handle).u64(offset).u32(length);
271
+ });
272
+ return reply.kind === "eof" ? null : expect(reply, "data", "reading").data;
273
+ }
274
+ async writeChunk(handle, offset, data) {
275
+ await this.#request(FXP.WRITE, "writing", (writer) => {
276
+ writer.string(handle).u64(offset).string(data);
277
+ });
278
+ }
279
+ /** Attributes, following symlinks. Null when there is nothing at that path. */
280
+ async stat(path) {
281
+ const operation = `checking ${path}`;
282
+ try {
283
+ const reply = await this.#request(FXP.STAT, operation, (writer) => {
284
+ writer.string(path);
285
+ });
286
+ return expect(reply, "attrs", operation).attrs;
287
+ } catch (error) {
288
+ if (error instanceof SftpError && error.missing) return null;
289
+ throw error;
290
+ }
291
+ }
292
+ async fstat(handle) {
293
+ const operation = "checking an open file";
294
+ const reply = await this.#request(FXP.FSTAT, operation, (writer) => {
295
+ writer.string(handle);
296
+ });
297
+ return expect(reply, "attrs", operation).attrs;
298
+ }
299
+ async setMode(path, mode) {
300
+ await this.#request(FXP.SETSTAT, `setting the mode of ${path}`, (writer) => {
301
+ writeAttrs(writer.string(path), mode);
302
+ });
303
+ }
304
+ /** Create a directory. A directory that already exists is not an error here. */
305
+ async mkdir(path, mode = 493) {
306
+ try {
307
+ await this.#request(FXP.MKDIR, `creating ${path}`, (writer) => {
308
+ writeAttrs(writer.string(path), mode);
309
+ });
310
+ } catch (error) {
311
+ const existing = await this.stat(path).catch(() => null);
312
+ if (!existing?.directory) throw error;
313
+ }
314
+ }
315
+ async remove(path) {
316
+ await this.#request(FXP.REMOVE, `removing ${path}`, (writer) => {
317
+ writer.string(path);
318
+ });
319
+ }
320
+ async rmdir(path) {
321
+ await this.#request(FXP.RMDIR, `removing ${path}`, (writer) => {
322
+ writer.string(path);
323
+ });
324
+ }
325
+ async rename(from, to) {
326
+ await this.#request(FXP.RENAME, `renaming ${from}`, (writer) => {
327
+ writer.string(from).string(to);
328
+ });
329
+ }
330
+ /**
331
+ * Everything in a directory, `.` and `..` left out.
332
+ *
333
+ * READDIR is called until it reports the end rather than once, because a
334
+ * server returns as many names as fitted in its reply and a directory with a
335
+ * few hundred files in it takes several.
336
+ */
337
+ async readdir(path) {
338
+ const operation = `listing ${path}`;
339
+ const opened = await this.#request(FXP.OPENDIR, operation, (writer) => {
340
+ writer.string(path);
341
+ });
342
+ const handle = expect(opened, "handle", operation).handle;
343
+ const entries = [];
344
+ try {
345
+ for (; ; ) {
346
+ const reply = await this.#request(FXP.READDIR, operation, (writer) => {
347
+ writer.string(handle);
348
+ });
349
+ if (reply.kind === "eof") break;
350
+ for (const entry of expect(reply, "names", operation).names) {
351
+ if (entry.name !== "." && entry.name !== "..") entries.push(entry);
352
+ }
353
+ }
354
+ } finally {
355
+ await this.close(handle).catch(() => {
356
+ });
357
+ }
358
+ return entries;
359
+ }
360
+ /**
361
+ * What the host makes of a path, absolute.
362
+ *
363
+ * Worth asking rather than guessing: a relative path is resolved against the
364
+ * account's start directory, which is the server's business and not visible
365
+ * from here.
366
+ */
367
+ async realpath(path) {
368
+ const operation = `resolving ${path}`;
369
+ const reply = await this.#request(FXP.REALPATH, operation, (writer) => {
370
+ writer.string(path);
371
+ });
372
+ return expect(reply, "names", operation).names[0]?.name ?? path;
373
+ }
374
+ /**
375
+ * A path as typed, made absolute -- including a leading `~`.
376
+ *
377
+ * `~` is a shell convention, and SFTP has no shell in it: sftp-server would
378
+ * look for a directory actually named `~` and report that there is none. But
379
+ * a relative path *is* resolved against the account's start directory, which
380
+ * is the home directory, so `REALPATH .` is the answer to what `~` meant. It
381
+ * is one extra round trip and only for paths that need it.
382
+ */
383
+ async resolve(path) {
384
+ if (path !== "~" && !path.startsWith("~/")) return path;
385
+ const home = await this.realpath(".");
386
+ const rest = path.slice(1).replace(/^\//, "");
387
+ return rest ? `${home.replace(/\/$/, "")}/${rest}` : home;
388
+ }
389
+ };
390
+
391
+ // src/cli/copy.ts
392
+ var PIPELINE = 8;
393
+ function parseEndpoint(token) {
394
+ const colon = token.indexOf(":");
395
+ if (colon === -1) return { remote: false, session: "", path: token };
396
+ const prefix = token.slice(0, colon);
397
+ if (prefix.length === 1) return { remote: false, session: "", path: token };
398
+ return { remote: true, session: prefix, path: token.slice(colon + 1) };
399
+ }
400
+ function destinationPath(destination, sourceName, isDirectory) {
401
+ if (!isDirectory) return destination;
402
+ const separator = destination.endsWith("/") || destination.endsWith("\\") ? "" : "/";
403
+ return `${destination}${separator}${sourceName}`;
404
+ }
405
+ function formatBytes(bytes) {
406
+ if (bytes < 1024) return `${bytes} B`;
407
+ const units = ["KB", "MB", "GB", "TB"];
408
+ let value = bytes / 1024;
409
+ let unit = 0;
410
+ while (value >= 1024 && unit < units.length - 1) {
411
+ value /= 1024;
412
+ unit++;
413
+ }
414
+ return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`;
415
+ }
416
+ function formatRate(bytes, milliseconds) {
417
+ if (milliseconds < 50 || bytes === 0) return "";
418
+ return `${formatBytes(Math.round(bytes * 1e3 / milliseconds))}/s`;
419
+ }
420
+ var SftpConnection = class {
421
+ sftp;
422
+ /**
423
+ * `SshSession` is untyped JavaScript in a `.ts` file, so `any` is the honest
424
+ * description of it -- the same as `shell.ts` does with the same class. The
425
+ * listeners below annotate their own events, which is where the shape
426
+ * actually matters.
427
+ */
428
+ #session;
429
+ #error = "";
430
+ constructor(options) {
431
+ this.#session = new SshSession(`tcp://${options.host}:${options.port}`, {
432
+ username: options.username,
433
+ password: options.password ?? "",
434
+ identities: options.identities ?? [],
435
+ subsystem: "sftp",
436
+ // To stderr, not stdout: a copy's stdout is its result, and with --json it
437
+ // is a document something else parses. The handshake is still visible,
438
+ // and still redirectable on its own.
439
+ log: (step, detail) => process.stderr.write(`[SSH] ${step} ${JSON.stringify(detail)}
440
+ `),
441
+ // The same reasoning as the terminal's: the person running the command
442
+ // named the host, on their own network, and there is no known_hosts here
443
+ // to compare against yet.
444
+ verifyHost: async () => true,
445
+ openTransport: () => new TcpTransport(options.host, options.port)
446
+ });
447
+ this.sftp = new Sftp({ write: (bytes) => this.#session.write(bytes) });
448
+ this.#session.addEventListener("data", (event) => {
449
+ const bytes = event.detail.bytes;
450
+ if (bytes?.length) this.sftp.receive(bytes);
451
+ });
452
+ this.#session.addEventListener("error", (event) => {
453
+ this.#error = event.detail?.message || this.#error;
454
+ });
455
+ this.#session.addEventListener("close", (event) => {
456
+ this.sftp.fail(this.#error || event.detail?.message || "The SFTP connection closed.");
457
+ });
458
+ }
459
+ async connect(timeoutMs = 3e4) {
460
+ await new Promise((resolve, reject) => {
461
+ let settled = false;
462
+ const finish = (error) => {
463
+ if (settled) return;
464
+ settled = true;
465
+ clearTimeout(timer);
466
+ if (error) reject(error);
467
+ else resolve();
468
+ };
469
+ const timer = setTimeout(
470
+ () => finish(new Error(`The host did not open an SFTP channel within ${Math.round(timeoutMs / 1e3)}s.`)),
471
+ timeoutMs
472
+ );
473
+ this.#session.addEventListener("ready", () => finish());
474
+ this.#session.addEventListener("close", (event) => finish(
475
+ new Error(this.#error || event.detail?.message || "The SSH connection closed during sign-in.")
476
+ ));
477
+ this.#session.connect();
478
+ });
479
+ await this.sftp.handshake();
480
+ }
481
+ close() {
482
+ try {
483
+ this.#session.disconnect();
484
+ } catch {
485
+ }
486
+ }
487
+ };
488
+ async function pipeline(tasks) {
489
+ const running = /* @__PURE__ */ new Set();
490
+ let failure = null;
491
+ const start = (task) => {
492
+ const tracked = task().then(
493
+ () => {
494
+ running.delete(tracked);
495
+ return void 0;
496
+ },
497
+ (error) => {
498
+ running.delete(tracked);
499
+ failure ??= error instanceof Error ? error : new Error(String(error));
500
+ return void 0;
501
+ }
502
+ );
503
+ running.add(tracked);
504
+ };
505
+ for (const task of tasks) {
506
+ if (failure) break;
507
+ start(task);
508
+ while (running.size >= PIPELINE) await Promise.race(running);
509
+ }
510
+ while (running.size) await Promise.race(running);
511
+ if (failure) throw failure;
512
+ }
513
+ async function readExact(sftp, handle, offset, length) {
514
+ const parts = [];
515
+ let got = 0;
516
+ while (got < length) {
517
+ const piece = await sftp.read(handle, offset + got, length - got);
518
+ if (!piece?.length) break;
519
+ parts.push(piece);
520
+ got += piece.length;
521
+ }
522
+ if (parts.length === 1) return parts[0];
523
+ const joined = new Uint8Array(got);
524
+ let at = 0;
525
+ for (const part of parts) {
526
+ joined.set(part, at);
527
+ at += part.length;
528
+ }
529
+ return joined;
530
+ }
531
+ function chunkOffsets(size) {
532
+ const offsets = [];
533
+ for (let offset = 0; offset < size; offset += CHUNK) offsets.push(offset);
534
+ return offsets;
535
+ }
536
+ async function upload(sftp, localPath, remotePath, onProgress = () => {
537
+ }) {
538
+ const info = await statFile(localPath);
539
+ const source = await openFile(localPath, "r");
540
+ const handle = await sftp.open(
541
+ remotePath,
542
+ OPEN_FLAGS.WRITE | OPEN_FLAGS.CREATE | OPEN_FLAGS.TRUNCATE,
543
+ info.mode & 511
544
+ );
545
+ let copied = 0;
546
+ try {
547
+ await pipeline(chunkOffsets(info.size).map((offset) => async () => {
548
+ const buffer = new Uint8Array(Math.min(CHUNK, info.size - offset));
549
+ let filled = 0;
550
+ while (filled < buffer.length) {
551
+ const { bytesRead } = await source.read(buffer, filled, buffer.length - filled, offset + filled);
552
+ if (!bytesRead) break;
553
+ filled += bytesRead;
554
+ }
555
+ await sftp.writeChunk(handle, offset, filled === buffer.length ? buffer : buffer.subarray(0, filled));
556
+ copied += filled;
557
+ onProgress(copied, info.size);
558
+ }));
559
+ } finally {
560
+ await source.close().catch(() => {
561
+ });
562
+ await sftp.close(handle).catch(() => {
563
+ });
564
+ }
565
+ return copied;
566
+ }
567
+ async function download(sftp, remotePath, localPath, onProgress = () => {
568
+ }) {
569
+ const handle = await sftp.open(remotePath, OPEN_FLAGS.READ);
570
+ let copied = 0;
571
+ try {
572
+ const attrs = await sftp.fstat(handle);
573
+ const size = attrs.size;
574
+ const target = await openFile(localPath, "w");
575
+ try {
576
+ if (size === null) {
577
+ for (let offset = 0; ; ) {
578
+ const piece = await readExact(sftp, handle, offset, CHUNK);
579
+ if (!piece.length) break;
580
+ await target.write(piece, 0, piece.length, offset);
581
+ offset += piece.length;
582
+ copied += piece.length;
583
+ onProgress(copied, null);
584
+ if (piece.length < CHUNK) break;
585
+ }
586
+ } else {
587
+ await pipeline(chunkOffsets(size).map((offset) => async () => {
588
+ const piece = await readExact(sftp, handle, offset, Math.min(CHUNK, size - offset));
589
+ if (piece.length) await target.write(piece, 0, piece.length, offset);
590
+ copied += piece.length;
591
+ onProgress(copied, size);
592
+ }));
593
+ }
594
+ if (attrs.permissions !== null) await target.chmod(attrs.permissions & 511).catch(() => {
595
+ });
596
+ } finally {
597
+ await target.close().catch(() => {
598
+ });
599
+ }
600
+ } finally {
601
+ await sftp.close(handle).catch(() => {
602
+ });
603
+ }
604
+ return copied;
605
+ }
606
+ async function relay(from, fromPath, to, toPath, onProgress = () => {
607
+ }) {
608
+ const source = await from.open(fromPath, OPEN_FLAGS.READ);
609
+ let copied = 0;
610
+ try {
611
+ const attrs = await from.fstat(source);
612
+ const target = await to.open(
613
+ toPath,
614
+ OPEN_FLAGS.WRITE | OPEN_FLAGS.CREATE | OPEN_FLAGS.TRUNCATE,
615
+ attrs.permissions === null ? null : attrs.permissions & 511
616
+ );
617
+ try {
618
+ if (attrs.size === null) {
619
+ for (let offset = 0; ; ) {
620
+ const piece = await readExact(from, source, offset, CHUNK);
621
+ if (!piece.length) break;
622
+ await to.writeChunk(target, offset, piece);
623
+ offset += piece.length;
624
+ copied += piece.length;
625
+ onProgress(copied, null);
626
+ if (piece.length < CHUNK) break;
627
+ }
628
+ } else {
629
+ const size = attrs.size;
630
+ await pipeline(chunkOffsets(size).map((offset) => async () => {
631
+ const piece = await readExact(from, source, offset, Math.min(CHUNK, size - offset));
632
+ if (piece.length) await to.writeChunk(target, offset, piece);
633
+ copied += piece.length;
634
+ onProgress(copied, size);
635
+ }));
636
+ }
637
+ } finally {
638
+ await to.close(target).catch(() => {
639
+ });
640
+ }
641
+ } finally {
642
+ await from.close(source).catch(() => {
643
+ });
644
+ }
645
+ return copied;
646
+ }
647
+ async function walkLocal(root, prefix = "") {
648
+ const found = [];
649
+ for (const entry of await readdir(root, { withFileTypes: true })) {
650
+ const here = prefix ? `${prefix}/${entry.name}` : entry.name;
651
+ if (entry.isDirectory()) found.push(...await walkLocal(join(root, entry.name), here));
652
+ else if (entry.isFile()) found.push({ relative: here, size: (await statFile(join(root, entry.name))).size });
653
+ }
654
+ return found;
655
+ }
656
+ async function walkRemote(sftp, root, prefix = "") {
657
+ const found = [];
658
+ for (const entry of await sftp.readdir(root)) {
659
+ const here = prefix ? `${prefix}/${entry.name}` : entry.name;
660
+ if (entry.attrs.directory) found.push(...await walkRemote(sftp, `${root}/${entry.name}`, here));
661
+ else found.push({ relative: here, size: entry.attrs.size ?? 0 });
662
+ }
663
+ return found;
664
+ }
665
+ async function ensureRemoteDirs(sftp, root, files) {
666
+ const needed = /* @__PURE__ */ new Set();
667
+ for (const file of files) {
668
+ const parts = file.relative.split("/").slice(0, -1);
669
+ for (let depth = 1; depth <= parts.length; depth++) needed.add(parts.slice(0, depth).join("/"));
670
+ }
671
+ await sftp.mkdir(root);
672
+ for (const directory of [...needed].toSorted((a, b) => a.split("/").length - b.split("/").length)) {
673
+ await sftp.mkdir(`${root}/${directory}`);
674
+ }
675
+ }
676
+ async function ensureLocalDirs(root, files) {
677
+ await mkdir(root, { recursive: true });
678
+ const needed = /* @__PURE__ */ new Set();
679
+ for (const file of files) {
680
+ const parent = dirname(file.relative);
681
+ if (parent && parent !== ".") needed.add(parent);
682
+ }
683
+ for (const directory of needed) await mkdir(join(root, directory), { recursive: true });
684
+ }
685
+ async function localKind(path) {
686
+ try {
687
+ const info = await statFile(path);
688
+ return info.isDirectory() ? "directory" : "file";
689
+ } catch {
690
+ return "missing";
691
+ }
692
+ }
693
+ async function remoteKind(sftp, path) {
694
+ const attrs = await sftp.stat(path).catch((error) => {
695
+ if (error instanceof SftpError && error.missing) return null;
696
+ throw error;
697
+ });
698
+ if (!attrs) return "missing";
699
+ return attrs.directory ? "directory" : "file";
700
+ }
701
+ export {
702
+ SftpConnection,
703
+ destinationPath,
704
+ download,
705
+ ensureLocalDirs,
706
+ ensureRemoteDirs,
707
+ formatBytes,
708
+ formatRate,
709
+ localKind,
710
+ parseEndpoint,
711
+ basename as pathName,
712
+ relay,
713
+ remoteKind,
714
+ resolveLocal,
715
+ upload,
716
+ walkLocal,
717
+ walkRemote
718
+ };