@love-moon/conductor-cli 0.11.2 → 0.12.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.
@@ -0,0 +1,796 @@
1
+ /**
2
+ * `conductor remote cp` — copy one file between here and another daemon's host.
3
+ *
4
+ * Follows scp's shape: exactly one of the two paths carries a `daemon:` prefix,
5
+ * and that decides the direction. The bytes never travel over the agent
6
+ * WebSocket — they go through two ordinary HTTP streams via the backend, which
7
+ * only relays a small control message on the socket. See RFC 0037.
8
+ */
9
+
10
+ import fs from "node:fs";
11
+ import fsp from "node:fs/promises";
12
+ import path from "node:path";
13
+ import os from "node:os";
14
+ import process from "node:process";
15
+ import { createHash } from "node:crypto";
16
+ import { Readable } from "node:stream";
17
+ import { pipeline } from "node:stream/promises";
18
+
19
+ import {
20
+ EXIT,
21
+ UsageError,
22
+ callApi,
23
+ delay,
24
+ getStream,
25
+ isRetryable,
26
+ loadCliConfig,
27
+ parseTimeoutMs as parseSharedTimeoutMs,
28
+ putStream,
29
+ withRetry,
30
+ } from "./client.js";
31
+ import { execRemoteOrThrow } from "./exec.js";
32
+ import {
33
+ CLEANUP_SCRIPT,
34
+ EXTRACT_SCRIPT,
35
+ PACK_SCRIPT,
36
+ localTempPath,
37
+ packLocalDirectory,
38
+ remoteTempPath,
39
+ shellArgs,
40
+ unpackLocalDirectory,
41
+ } from "./archive.js";
42
+
43
+ export { EXIT, UsageError };
44
+
45
+ /**
46
+ * 1 GiB on a 5 Mbit/s uplink is roughly 27 minutes, and cutting a transfer off
47
+ * mid-flight is worse than letting a slow one finish — especially now that a
48
+ * stalled chunk is retried rather than fatal. `--timeout` remains the overall
49
+ * deadline for anyone who wants a tighter one.
50
+ */
51
+ const DEFAULT_TIMEOUT_MS = 1_800_000;
52
+
53
+ /**
54
+ * One request carries at most this much.
55
+ *
56
+ * Chunking is what makes a transfer resumable, and it is also what keeps the
57
+ * server safe at a 1 GiB file limit: a single request is bounded in duration
58
+ * and in how much any proxy in the path has to hold, so nginx's body cap can
59
+ * stay small and a failure costs one chunk instead of the whole file.
60
+ */
61
+ const DEFAULT_CHUNK_BYTES = 32 * 1024 * 1024;
62
+
63
+ /** Overridable so a flaky link can shrink it — and so tests can exercise the
64
+ * multi-chunk path without moving 32 MiB around. */
65
+ export function chunkBytes(env = process.env) {
66
+ const raw = Number.parseInt(env.CONDUCTOR_REMOTE_CHUNK_BYTES || "", 10);
67
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_CHUNK_BYTES;
68
+ }
69
+
70
+ const VALUE_FLAGS = new Map([
71
+ ["--target", "target"],
72
+ ["-t", "target"],
73
+ ["--timeout", "timeout"],
74
+ ["--config-file", "configFile"],
75
+ ]);
76
+
77
+ const BOOL_FLAGS = new Map([
78
+ ["--json", "json"],
79
+ ["--recursive", "recursive"],
80
+ ["-r", "recursive"],
81
+ ["--help", "help"],
82
+ ["-h", "help"],
83
+ ]);
84
+
85
+ export function parseCpArgs(argv) {
86
+ const options = { json: false, help: false, recursive: false };
87
+ const paths = [];
88
+
89
+ let index = 0;
90
+ while (index < argv.length) {
91
+ const token = argv[index];
92
+
93
+ if (token === "--") {
94
+ paths.push(...argv.slice(index + 1));
95
+ break;
96
+ }
97
+
98
+ let name = token;
99
+ let inlineValue = null;
100
+ if (token.startsWith("--") && token.includes("=")) {
101
+ const splitAt = token.indexOf("=");
102
+ name = token.slice(0, splitAt);
103
+ inlineValue = token.slice(splitAt + 1);
104
+ }
105
+
106
+ if (BOOL_FLAGS.has(name) && inlineValue === null) {
107
+ options[BOOL_FLAGS.get(name)] = true;
108
+ index += 1;
109
+ continue;
110
+ }
111
+
112
+ if (VALUE_FLAGS.has(name)) {
113
+ const key = VALUE_FLAGS.get(name);
114
+ const value = inlineValue !== null ? inlineValue : argv[index + 1];
115
+ if (value === undefined) {
116
+ throw new UsageError(`${name} requires a value`);
117
+ }
118
+ options[key] = value;
119
+ index += inlineValue !== null ? 1 : 2;
120
+ continue;
121
+ }
122
+
123
+ if (name.startsWith("-") && name !== "-") {
124
+ throw new UsageError(`unknown option: ${token}`);
125
+ }
126
+
127
+ paths.push(token);
128
+ index += 1;
129
+ }
130
+
131
+ return { options, paths };
132
+ }
133
+
134
+ export function parseTimeoutMs(value) {
135
+ return parseSharedTimeoutMs(value, DEFAULT_TIMEOUT_MS);
136
+ }
137
+
138
+ /**
139
+ * Decide whether a path argument names a daemon.
140
+ *
141
+ * `daemon:/path` and `:/path` (with `--target`) are remote; anything starting
142
+ * with `/`, `.` or `~` is local even when it contains a colon, so a local file
143
+ * literally named `a:b` stays reachable as `./a:b`.
144
+ */
145
+ export function parseRemoteSpec(value, defaultTarget) {
146
+ if (typeof value !== "string" || value === "") {
147
+ return null;
148
+ }
149
+ if (value.startsWith("/") || value.startsWith(".") || value.startsWith("~")) {
150
+ return null;
151
+ }
152
+ const match = value.match(/^([^:/]*):(.+)$/);
153
+ if (!match) {
154
+ return null;
155
+ }
156
+ const target = (match[1] || defaultTarget || "").trim();
157
+ if (!target) {
158
+ throw new UsageError(`no daemon in "${value}" and no --target given`);
159
+ }
160
+ return { target, path: match[2] };
161
+ }
162
+
163
+ export function formatBytes(bytes) {
164
+ if (!Number.isFinite(bytes)) return "? B";
165
+ const units = ["B", "KB", "MB", "GB"];
166
+ let value = bytes;
167
+ let unit = 0;
168
+ while (value >= 1024 && unit < units.length - 1) {
169
+ value /= 1024;
170
+ unit += 1;
171
+ }
172
+ return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
173
+ }
174
+
175
+ function expandHome(value) {
176
+ if (value === "~") return os.homedir();
177
+ if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
178
+ return value;
179
+ }
180
+
181
+ async function hashLocalFile(filePath) {
182
+ const hash = createHash("sha256");
183
+ for await (const chunk of fs.createReadStream(filePath)) {
184
+ hash.update(chunk);
185
+ }
186
+ return hash.digest("hex");
187
+ }
188
+
189
+ export function showHelp(consoleImpl = console) {
190
+ consoleImpl.log(`conductor remote cp - copy a file to or from another daemon's host
191
+
192
+ Usage:
193
+ conductor remote cp [options] <src> <dst>
194
+
195
+ Exactly one of <src> and <dst> must name a daemon as <daemon>:<path>.
196
+
197
+ Options:
198
+ -r, --recursive Copy a directory and everything under it
199
+ -t, --target <daemon> Daemon to use when a path is written as :<path>
200
+ --timeout <dur> Overall deadline, e.g. 30s, 5m (default: 30m)
201
+ --json Print the raw transfer result as JSON
202
+ --config-file <p> Conductor config file to authenticate with
203
+ -h, --help Show this help
204
+
205
+ Notes:
206
+ Contents are verified end to end with SHA-256, and the destination is written
207
+ via a temporary file and renamed, so an interrupted copy never leaves a
208
+ half-written file in place.
209
+
210
+ -r packs the directory with tar, moves the single tarball, and unpacks it on
211
+ the other side, so permissions and symlinks survive. It needs tar and the
212
+ remote_exec capability on the target, and the *compressed* size must fit under
213
+ the transfer limit. Like scp: an existing destination directory receives the
214
+ source inside it, a missing one becomes the copy itself.
215
+
216
+ Examples:
217
+ conductor remote cp ./build.tar.gz ubuntu:/srv/app/build.tar.gz
218
+ conductor remote cp ubuntu:/var/log/conductor.log ./conductor.log
219
+ conductor remote cp -r ./dist ubuntu:/srv/app # -> /srv/app/dist
220
+ conductor remote cp -r ubuntu:/srv/app/logs ./logs # -> ./logs
221
+ conductor remote cp -t ubuntu ./a.bin :/tmp/a.bin
222
+ `);
223
+ }
224
+
225
+ /**
226
+ * Send a file as a sequence of ranged PUTs, resuming across failures.
227
+ *
228
+ * The server owns the truth about how much it has: a 409 carries the real
229
+ * `receivedBytes`, and we seek there rather than failing. That single rule
230
+ * covers both a mid-transfer retry and a chunk the server only half-accepted.
231
+ */
232
+ const POLL_INTERVAL_MS = 1_000;
233
+ const TERMINAL_STATUSES = new Set(["ready", "failed", "cancelled"]);
234
+
235
+ /**
236
+ * Wait for a transfer the daemon is still working on.
237
+ *
238
+ * Moving real bytes takes far longer than any HTTP request should be held
239
+ * open: a 1 GiB pull outlives both nginx's 60s read timeout and the backend's
240
+ * websocket wait. So the backend answers as soon as it knows the daemon has
241
+ * *started*, and we poll for the outcome — the same two-phase shape
242
+ * `remote exec` already uses for long commands.
243
+ */
244
+ async function awaitTransfer({
245
+ config, statusPath, initial, fetchImpl, deadline, sleep = delay, now = () => Date.now(),
246
+ }) {
247
+ let record = initial;
248
+ while (!TERMINAL_STATUSES.has(record?.status)) {
249
+ if (typeof deadline === "number" && now() >= deadline) {
250
+ throw new Error(`transfer did not finish before the deadline (last status: ${record?.status})`);
251
+ }
252
+ await sleep(POLL_INTERVAL_MS);
253
+ try {
254
+ record = await callApi(config, "GET", statusPath, null, fetchImpl);
255
+ } catch (error) {
256
+ // One failed poll must not abort a transfer that is still healthy; the
257
+ // deadline above is what ends it.
258
+ if (!isRetryable(error)) throw error;
259
+ }
260
+ }
261
+ if (record.status !== "ready") {
262
+ throw new Error(record.error || `transfer ${record.status}`);
263
+ }
264
+ return record;
265
+ }
266
+
267
+ /** Show progress on stderr for transfers large enough that silence looks hung. */
268
+ function makeProgress(consoleImpl, label, quiet) {
269
+ const MIN_BYTES = 8 * 1024 * 1024;
270
+ const INTERVAL_MS = 1000;
271
+ let lastAt = 0;
272
+ return (done, total) => {
273
+ if (quiet || !Number.isFinite(total) || total < MIN_BYTES) return;
274
+ const now = Date.now();
275
+ const finished = done >= total;
276
+ if (!finished && now - lastAt < INTERVAL_MS) return;
277
+ lastAt = now;
278
+ const pct = total > 0 ? Math.floor((done / total) * 100) : 100;
279
+ // \r keeps it on one line; the caller prints a real line when it finishes.
280
+ process.stderr.write(
281
+ `\r[conductor] ${label} ${formatBytes(done)}/${formatBytes(total)} (${pct}%)${finished ? "\n" : ""}`,
282
+ );
283
+ };
284
+ }
285
+
286
+ async function uploadChunks({
287
+ config, contentPath, filePath, totalBytes, fetchImpl, signal, sleep = delay, onProgress, env,
288
+ }) {
289
+ const chunk = chunkBytes(env);
290
+ let offset = 0;
291
+ let result = null;
292
+
293
+ // A zero-byte file still needs one request to mark the transfer complete.
294
+ while (offset < totalBytes || (totalBytes === 0 && !result)) {
295
+ result = await withRetry(async () => {
296
+ // Recompute the range on every attempt. A retry may be happening
297
+ // *because* the server told us our offset was stale, so re-sending the
298
+ // range we captured before would just earn another 409.
299
+ const start = offset;
300
+ const end = Math.min(start + chunk, totalBytes) - 1;
301
+ try {
302
+ return await putStream(
303
+ config,
304
+ contentPath,
305
+ Readable.toWeb(
306
+ totalBytes === 0
307
+ ? Readable.from([])
308
+ : fs.createReadStream(filePath, { start, end }),
309
+ ),
310
+ {
311
+ sizeBytes: totalBytes === 0 ? 0 : end - start + 1,
312
+ // An empty file has no satisfiable byte range at all (`bytes 0-0/0`
313
+ // is malformed, and `0--1/0` is nonsense), so send it as a plain
314
+ // whole-body PUT and let the server record a complete zero-byte
315
+ // transfer.
316
+ ...(totalBytes === 0
317
+ ? {}
318
+ : { contentRange: `bytes ${start}-${end}/${totalBytes}` }),
319
+ fetchImpl,
320
+ signal,
321
+ },
322
+ );
323
+ } catch (error) {
324
+ // Not really a failure: the server is telling us where it actually is.
325
+ // Move there and let `withRetry` take another pass.
326
+ if (error?.status === 409 && typeof error.receivedBytes === "number") {
327
+ offset = error.receivedBytes;
328
+ throw Object.assign(error, { status: 503 });
329
+ }
330
+ throw error;
331
+ }
332
+ }, { sleep });
333
+
334
+ const advanced = typeof result?.receivedBytes === "number"
335
+ ? result.receivedBytes
336
+ : Math.min(offset + chunk, totalBytes);
337
+ if (advanced <= offset && totalBytes > 0) {
338
+ throw new Error(`upload stalled at ${offset}/${totalBytes} bytes`);
339
+ }
340
+ offset = advanced;
341
+ onProgress?.(offset, totalBytes);
342
+ if (result?.complete || totalBytes === 0) break;
343
+ }
344
+
345
+ return result;
346
+ }
347
+
348
+ /**
349
+ * Pull a file down, resuming from whatever is already in the local `.part`.
350
+ *
351
+ * The hash has to be computed over the finished file rather than streamed,
352
+ * because a resumed download never sees the earlier bytes go past.
353
+ */
354
+ async function downloadChunks({
355
+ config, contentPath, temporary, expectedBytes, fetchImpl, signal, sleep = delay, onProgress,
356
+ }) {
357
+ await withRetry(async () => {
358
+ const have = await fsp.stat(temporary).then((st) => st.size).catch(() => 0);
359
+ if (Number.isFinite(expectedBytes) && have >= expectedBytes) return;
360
+
361
+ const response = await getStream(config, contentPath, { fetchImpl, signal, rangeFrom: have });
362
+
363
+ // 416 means the server has nothing past what we already hold.
364
+ if (response.status === 416) return;
365
+
366
+ // A server that ignored our Range restarts the file, so our partial is
367
+ // stale and must go — appending to it would interleave two copies.
368
+ const resuming = response.status === 206 && have > 0;
369
+ if (!resuming && have > 0) {
370
+ await fsp.rm(temporary, { force: true });
371
+ }
372
+ if (!response.body) throw new Error("backend returned an empty body");
373
+
374
+ await pipeline(
375
+ Readable.fromWeb(response.body),
376
+ fs.createWriteStream(temporary, { flags: resuming ? "a" : "w", mode: 0o600 }),
377
+ );
378
+
379
+ const now = await fsp.stat(temporary).then((st) => st.size).catch(() => 0);
380
+ onProgress?.(now, expectedBytes);
381
+ if (Number.isFinite(expectedBytes) && now < expectedBytes) {
382
+ // Ran short: throw so `withRetry` resumes from the new offset.
383
+ throw new Error(`transfer ended early at ${now}/${expectedBytes} bytes`);
384
+ }
385
+ }, { sleep });
386
+ }
387
+
388
+ async function uploadFile(ctx) {
389
+ const { config, target, localPath, remotePath, fetchImpl, consoleImpl, signal, quiet } = ctx;
390
+ const resolvedLocal = path.resolve(expandHome(localPath));
391
+
392
+ let stat;
393
+ try {
394
+ stat = await fsp.stat(resolvedLocal);
395
+ } catch (error) {
396
+ if (error?.code === "ENOENT") {
397
+ throw new UsageError(`no such file: ${resolvedLocal}`);
398
+ }
399
+ throw error;
400
+ }
401
+ if (stat.isDirectory()) {
402
+ throw new UsageError(
403
+ `${resolvedLocal} is a directory; pass -r to copy it recursively`,
404
+ );
405
+ }
406
+ if (!stat.isFile()) {
407
+ throw new UsageError(`not a regular file: ${resolvedLocal}`);
408
+ }
409
+
410
+ const sha256 = await hashLocalFile(resolvedLocal);
411
+ const basePath = `/api/agents/${encodeURIComponent(target)}/files`;
412
+
413
+ const created = await callApi(config, "POST", basePath, {
414
+ direction: "up",
415
+ remotePath,
416
+ name: path.basename(resolvedLocal),
417
+ sizeBytes: stat.size,
418
+ sha256,
419
+ mode: stat.mode & 0o777,
420
+ }, fetchImpl);
421
+
422
+ const transferId = created?.transferId;
423
+ if (!transferId) {
424
+ throw new Error("backend did not return a transferId");
425
+ }
426
+
427
+ await uploadChunks({
428
+ config,
429
+ contentPath: `${basePath}/${encodeURIComponent(transferId)}/content`,
430
+ filePath: resolvedLocal,
431
+ totalBytes: stat.size,
432
+ fetchImpl,
433
+ signal,
434
+ sleep: ctx.sleep,
435
+ onProgress: ctx.onProgress || makeProgress(consoleImpl, `uploading to ${target}:`, quiet),
436
+ env: ctx.env,
437
+ });
438
+
439
+ let delivered = await callApi(
440
+ config,
441
+ "POST",
442
+ `${basePath}/${encodeURIComponent(transferId)}/deliver`,
443
+ {},
444
+ fetchImpl,
445
+ );
446
+
447
+ // A daemon-side failure comes back as HTTP 200 with `status: "failed"`,
448
+ // because the request itself succeeded — only the transfer did not.
449
+ if (delivered?.status === "failed" || delivered?.error) {
450
+ throw new Error(delivered?.error || `transfer failed on ${target}`);
451
+ }
452
+ // ...and a large file is still being written when `deliver` answers.
453
+ delivered = await awaitTransfer({
454
+ config,
455
+ statusPath: `${basePath}/${encodeURIComponent(transferId)}`,
456
+ initial: delivered,
457
+ fetchImpl,
458
+ deadline: ctx.deadline,
459
+ sleep: ctx.sleep,
460
+ });
461
+ if (!quiet) {
462
+ consoleImpl.error(
463
+ `[conductor] uploaded ${formatBytes(stat.size)} to ${target}:${delivered?.path || remotePath}`,
464
+ );
465
+ }
466
+ return { ...delivered, transferId, direction: "up", sizeBytes: stat.size, sha256 };
467
+ }
468
+
469
+ async function downloadFile(ctx) {
470
+ const { config, target, remotePath, localPath, fetchImpl, consoleImpl, signal, quiet } = ctx;
471
+ const basePath = `/api/agents/${encodeURIComponent(target)}/files`;
472
+
473
+ let created = await callApi(config, "POST", basePath, {
474
+ direction: "down",
475
+ remotePath,
476
+ }, fetchImpl);
477
+
478
+ if (created?.status === "failed" || created?.error) {
479
+ throw new Error(created?.error || `${target} could not read ${remotePath}`);
480
+ }
481
+ const transferId = created?.transferId;
482
+ if (!transferId) {
483
+ throw new Error("backend did not return a transferId");
484
+ }
485
+ // For anything but a small file the daemon is still uploading at this point.
486
+ created = await awaitTransfer({
487
+ config,
488
+ statusPath: `${basePath}/${encodeURIComponent(transferId)}`,
489
+ initial: created,
490
+ fetchImpl,
491
+ deadline: ctx.deadline,
492
+ sleep: ctx.sleep,
493
+ });
494
+
495
+ // scp semantics: copying onto an existing directory keeps the source name.
496
+ let destination = path.resolve(expandHome(localPath));
497
+ try {
498
+ const destStat = await fsp.stat(destination);
499
+ if (destStat.isDirectory()) {
500
+ destination = path.join(destination, created.name || path.basename(remotePath));
501
+ }
502
+ } catch {
503
+ // Missing destination is the normal case; the parent must exist though.
504
+ }
505
+
506
+ // Check the parent before pulling any bytes: failing afterwards would waste
507
+ // the whole transfer, and the raw ENOENT names the internal `.part` file,
508
+ // which tells the user nothing about what is actually wrong.
509
+ const parent = path.dirname(destination);
510
+ try {
511
+ const parentStat = await fsp.stat(parent);
512
+ if (!parentStat.isDirectory()) {
513
+ throw new UsageError(`not a directory: ${parent}`);
514
+ }
515
+ } catch (error) {
516
+ if (error instanceof UsageError) throw error;
517
+ if (error?.code === "ENOENT") {
518
+ throw new UsageError(`destination directory does not exist: ${parent}`);
519
+ }
520
+ throw error;
521
+ }
522
+
523
+ // Demand the checksum and size up front rather than verifying them only when
524
+ // present. A conditional check is worse than none: if the backend ever stops
525
+ // sending the field, `undefined` quietly satisfies the guard and every
526
+ // corrupt download is renamed into place with exit code 0. That bug shipped
527
+ // once already. Checking before the transfer also avoids wasting it.
528
+ if (!created.sha256) {
529
+ throw new Error("backend did not report a checksum; refusing to write an unverified file");
530
+ }
531
+ if (!Number.isFinite(created.sizeBytes)) {
532
+ throw new Error("backend did not report a size; refusing to write an unverified file");
533
+ }
534
+
535
+ const temporary = `${destination}.${process.pid}.part`;
536
+ let received = 0;
537
+ try {
538
+ await downloadChunks({
539
+ config,
540
+ contentPath: `${basePath}/${encodeURIComponent(transferId)}/content`,
541
+ temporary,
542
+ expectedBytes: created.sizeBytes,
543
+ fetchImpl,
544
+ signal,
545
+ sleep: ctx.sleep,
546
+ onProgress: ctx.onProgress || makeProgress(consoleImpl, `downloading from ${target}:`, quiet),
547
+ });
548
+
549
+ // Hash the assembled file, not the stream: a resumed download never sees
550
+ // the earlier bytes go by, so a streaming digest would cover only the last
551
+ // range and would happily "verify" a spliced-together mess.
552
+ received = (await fsp.stat(temporary)).size;
553
+ if (received !== created.sizeBytes) {
554
+ throw new Error(`size mismatch: expected ${created.sizeBytes} bytes, got ${received}`);
555
+ }
556
+ const digest = await hashLocalFile(temporary);
557
+ if (digest !== created.sha256) {
558
+ throw new Error(`checksum mismatch: expected ${created.sha256}, got ${digest}`);
559
+ }
560
+
561
+ await fsp.rename(temporary, destination);
562
+ if (Number.isInteger(created.mode)) {
563
+ await fsp.chmod(destination, created.mode & 0o777).catch(() => {});
564
+ }
565
+ } catch (error) {
566
+ await fsp.rm(temporary, { force: true }).catch(() => {});
567
+ throw error;
568
+ }
569
+
570
+ // Best effort: the server also reclaims this on a timer, but returning the
571
+ // slot now is what keeps four quick copies in a row from hitting the cap.
572
+ await callApi(config, "DELETE", `${basePath}/${encodeURIComponent(transferId)}`, null, fetchImpl)
573
+ .catch(() => {});
574
+
575
+ if (!quiet) {
576
+ consoleImpl.error(
577
+ `[conductor] downloaded ${formatBytes(received)} from ${target}:${remotePath} to ${destination}`,
578
+ );
579
+ }
580
+ return { ...created, transferId, direction: "down", sizeBytes: received, path: destination };
581
+ }
582
+
583
+ /**
584
+ * Budget for one remote `tar` step, always clamped to what is left of the
585
+ * caller's own `--timeout`. Without the clamp `--timeout 30s` would still let a
586
+ * pack run for minutes, because the archive steps go through `exec`, which has
587
+ * its own deadline and knows nothing about ours.
588
+ */
589
+ const ARCHIVE_STEP_TIMEOUT_MS = 600_000;
590
+
591
+ function archiveTimeout(ctx) {
592
+ const remaining = typeof ctx.deadline === "number" ? ctx.deadline - Date.now() : Infinity;
593
+ const budget = Math.min(ARCHIVE_STEP_TIMEOUT_MS, remaining);
594
+ if (budget <= 0) throw new Error("ran out of time before the archive step");
595
+ return budget;
596
+ }
597
+
598
+ async function uploadDirectory(ctx) {
599
+ const { config, target, localPath, remotePath, fetchImpl, consoleImpl, signal, env } = ctx;
600
+ const source = path.resolve(expandHome(localPath));
601
+ const baseName = path.basename(source);
602
+ const tarball = localTempPath();
603
+ const remoteTarball = remoteTempPath(env);
604
+
605
+ consoleImpl.error(`[conductor] packing ${source}`);
606
+ await packLocalDirectory(source, tarball);
607
+
608
+ try {
609
+ const uploaded = await uploadFile({
610
+ ...ctx,
611
+ localPath: tarball,
612
+ remotePath: remoteTarball,
613
+ quiet: true,
614
+ });
615
+
616
+ consoleImpl.error(`[conductor] unpacking on ${target}`);
617
+ await execRemoteOrThrow(config, target, "sh", {
618
+ args: shellArgs(EXTRACT_SCRIPT, remoteTarball, remotePath, baseName),
619
+ timeoutMs: archiveTimeout(ctx),
620
+ fetchImpl,
621
+ });
622
+
623
+ consoleImpl.error(
624
+ `[conductor] copied ${baseName}/ (${formatBytes(uploaded.sizeBytes)} compressed) to ${target}:${remotePath}`,
625
+ );
626
+ return { ...uploaded, direction: "up", recursive: true, path: remotePath };
627
+ } finally {
628
+ await fsp.rm(tarball, { force: true }).catch(() => {});
629
+ // Best effort: leaving a tarball in the target's /tmp is untidy, but it
630
+ // must never turn a completed copy into a failure.
631
+ await execRemoteOrThrow(config, target, "sh", {
632
+ args: shellArgs(CLEANUP_SCRIPT, remoteTarball),
633
+ timeoutMs: 30_000,
634
+ fetchImpl,
635
+ }).catch(() => {});
636
+ }
637
+ }
638
+
639
+ async function downloadDirectory(ctx) {
640
+ const { config, target, remotePath, localPath, fetchImpl, consoleImpl, env } = ctx;
641
+ const baseName = path.basename(remotePath.replace(/\/+$/, "")) || "download";
642
+ const tarball = localTempPath();
643
+ const remoteTarball = remoteTempPath(env);
644
+
645
+ consoleImpl.error(`[conductor] packing ${target}:${remotePath}`);
646
+ try {
647
+ await execRemoteOrThrow(config, target, "sh", {
648
+ args: shellArgs(PACK_SCRIPT, remotePath, remoteTarball),
649
+ timeoutMs: archiveTimeout(ctx),
650
+ fetchImpl,
651
+ });
652
+
653
+ const downloaded = await downloadFile({
654
+ ...ctx,
655
+ remotePath: remoteTarball,
656
+ localPath: tarball,
657
+ quiet: true,
658
+ });
659
+
660
+ const destination = await unpackLocalDirectory(
661
+ tarball,
662
+ path.resolve(expandHome(localPath)),
663
+ baseName,
664
+ );
665
+ consoleImpl.error(
666
+ `[conductor] copied ${baseName}/ (${formatBytes(downloaded.sizeBytes)} compressed) to ${destination}`,
667
+ );
668
+ return { ...downloaded, direction: "down", recursive: true, path: destination };
669
+ } finally {
670
+ await fsp.rm(tarball, { force: true }).catch(() => {});
671
+ await execRemoteOrThrow(config, target, "sh", {
672
+ args: shellArgs(CLEANUP_SCRIPT, remoteTarball),
673
+ timeoutMs: 30_000,
674
+ fetchImpl,
675
+ }).catch(() => {});
676
+ }
677
+ }
678
+
679
+ export async function runRemoteCp(argv, deps = {}) {
680
+ const consoleImpl = deps.console || console;
681
+ const fetchImpl = deps.fetch || globalThis.fetch;
682
+ const env = deps.env || process.env;
683
+ const sleep = deps.sleep || delay;
684
+
685
+ let parsed;
686
+ try {
687
+ parsed = parseCpArgs(argv);
688
+ } catch (error) {
689
+ consoleImpl.error(`Error: ${error.message}`);
690
+ return EXIT.CLI_ERROR;
691
+ }
692
+ const { options, paths } = parsed;
693
+
694
+ if (options.help) {
695
+ showHelp(consoleImpl);
696
+ return EXIT.OK;
697
+ }
698
+
699
+ if (paths.length !== 2) {
700
+ consoleImpl.error(
701
+ paths.length < 2
702
+ ? "Error: need both a source and a destination"
703
+ : `Error: expected exactly 2 paths, got ${paths.length}`,
704
+ );
705
+ showHelp(consoleImpl);
706
+ return EXIT.CLI_ERROR;
707
+ }
708
+
709
+ let sourceSpec;
710
+ let destSpec;
711
+ let timeoutMs;
712
+ try {
713
+ sourceSpec = parseRemoteSpec(paths[0], options.target);
714
+ destSpec = parseRemoteSpec(paths[1], options.target);
715
+ timeoutMs = parseTimeoutMs(options.timeout);
716
+ } catch (error) {
717
+ consoleImpl.error(`Error: ${error.message}`);
718
+ return EXIT.CLI_ERROR;
719
+ }
720
+
721
+ if (sourceSpec && destSpec) {
722
+ consoleImpl.error("Error: daemon-to-daemon copy is not supported; one side must be local");
723
+ return EXIT.CLI_ERROR;
724
+ }
725
+ if (!sourceSpec && !destSpec) {
726
+ consoleImpl.error("Error: one side must name a daemon as <daemon>:<path>");
727
+ showHelp(consoleImpl);
728
+ return EXIT.CLI_ERROR;
729
+ }
730
+
731
+ let config;
732
+ try {
733
+ config = deps.config || loadCliConfig(options.configFile, env);
734
+ } catch (error) {
735
+ consoleImpl.error(`Error: ${error.message}`);
736
+ return EXIT.CLI_ERROR;
737
+ }
738
+
739
+ const controller = new AbortController();
740
+ const deadline = Date.now() + timeoutMs;
741
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
742
+ if (typeof timer.unref === "function") timer.unref();
743
+
744
+ const ctx = destSpec
745
+ ? {
746
+ config,
747
+ target: destSpec.target,
748
+ localPath: paths[0],
749
+ remotePath: destSpec.path,
750
+ fetchImpl,
751
+ consoleImpl,
752
+ env,
753
+ sleep,
754
+ deadline,
755
+ signal: controller.signal,
756
+ }
757
+ : {
758
+ config,
759
+ target: sourceSpec.target,
760
+ remotePath: sourceSpec.path,
761
+ localPath: paths[1],
762
+ fetchImpl,
763
+ consoleImpl,
764
+ env,
765
+ sleep,
766
+ deadline,
767
+ signal: controller.signal,
768
+ };
769
+
770
+ try {
771
+ let result;
772
+ if (destSpec) {
773
+ // scp does the same: -r on a plain file is just a copy, not an error.
774
+ const sourceIsDirectory = options.recursive
775
+ && (await fsp.stat(path.resolve(expandHome(paths[0]))).catch(() => null))?.isDirectory();
776
+ result = sourceIsDirectory ? await uploadDirectory(ctx) : await uploadFile(ctx);
777
+ } else if (options.recursive) {
778
+ result = await downloadDirectory(ctx);
779
+ } else {
780
+ result = await downloadFile(ctx);
781
+ }
782
+
783
+ if (options.json) {
784
+ consoleImpl.log(JSON.stringify(result, null, 2));
785
+ }
786
+ return EXIT.OK;
787
+ } catch (error) {
788
+ const message = controller.signal.aborted
789
+ ? `transfer timed out after ${timeoutMs}ms`
790
+ : error.message;
791
+ consoleImpl.error(`Error: ${message}`);
792
+ return EXIT.CLI_ERROR;
793
+ } finally {
794
+ clearTimeout(timer);
795
+ }
796
+ }