@love-moon/conductor-cli 0.11.1 → 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.
package/src/daemon.js CHANGED
@@ -42,6 +42,11 @@ import {
42
42
  createRemoteExecHandlers,
43
43
  handleRemoteExecRequest,
44
44
  } from "./remote-exec-handlers.js";
45
+ import {
46
+ REMOTE_FILE_CAPABILITY,
47
+ createRemoteFileHandlers,
48
+ handleRemoteFileRequest,
49
+ } from "./remote-file-handlers.js";
45
50
  import {
46
51
  UPDATE_DAEMON_CAPABILITY,
47
52
  createDaemonUpdateHandlers,
@@ -288,6 +293,33 @@ function getRemoteExecEnabled(userConfig) {
288
293
  return true;
289
294
  }
290
295
 
296
+ // Whether this host will accept `conductor remote cp` file transfers (RFC
297
+ // 0037). Kept separate from `remote_exec` on purpose: the two are not distinct
298
+ // trust levels — anyone with exec can already `base64` a file — but a host may
299
+ // well want scripted commands without letting the account push bytes onto its
300
+ // disk, and a separate flag is the only way to say that.
301
+ //
302
+ // Resolution order:
303
+ // 1. CONDUCTOR_REMOTE_FILE env var ("1"/"true"/"on" enable, "0"/"false"/"off" disable)
304
+ // 2. remote_file boolean in the resolved Conductor config.yaml
305
+ // 3. Default: true
306
+ function getRemoteFileEnabled(userConfig) {
307
+ const rawEnv = process.env.CONDUCTOR_REMOTE_FILE;
308
+ if (typeof rawEnv === "string" && rawEnv.trim()) {
309
+ const normalized = rawEnv.trim().toLowerCase();
310
+ if (normalized === "1" || normalized === "true" || normalized === "on" || normalized === "yes") {
311
+ return true;
312
+ }
313
+ if (normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no") {
314
+ return false;
315
+ }
316
+ }
317
+ if (userConfig && typeof userConfig === "object" && userConfig.remote_file === false) {
318
+ return false;
319
+ }
320
+ return true;
321
+ }
322
+
291
323
  function normalizePlanLimitType(limitType) {
292
324
  if (typeof limitType !== "string") {
293
325
  return null;
@@ -672,10 +704,102 @@ export function buildFireSpawnArgs({ selectedBackend, initialContent, launchConf
672
704
  if (effectivePrefill) {
673
705
  args.push("--prefill", effectivePrefill);
674
706
  }
707
+ const resumeSessionId =
708
+ normalizeOptionalString(launchConfig?.resumeSessionId) ||
709
+ normalizeOptionalString(launchConfig?.resume_session_id);
710
+ if (resumeSessionId) {
711
+ args.push("--resume", resumeSessionId);
712
+ }
675
713
  args.push("--");
676
714
  return args;
677
715
  }
678
716
 
717
+ // Backends whose local session stores the ai-sdk resume module can enumerate.
718
+ // Kept in sync with the server side of the `list_backend_sessions` RPC.
719
+ const LISTABLE_SESSION_BACKENDS = ["claude", "codex", "kimi"];
720
+ const DEFAULT_BACKEND_SESSION_LIMIT = 20;
721
+ const MAX_BACKEND_SESSION_LIMIT = 50;
722
+
723
+ // Lazy so a daemon running against an older ai-sdk build only fails when the
724
+ // RPC is actually invoked (surfaced as a per-backend error), not at load time.
725
+ async function listSessionsForBackendFromSdk(backendType, options) {
726
+ let sdk = await import("@love-moon/ai-sdk");
727
+ if (typeof sdk.listSessionsForBackend !== "function") {
728
+ // The root index can lag behind the resume module the function lives in;
729
+ // fall back to that entry point before declaring the sdk too old.
730
+ sdk = await import("@love-moon/ai-sdk/dist/resume/index.js").catch(() => sdk);
731
+ }
732
+ if (typeof sdk.listSessionsForBackend !== "function") {
733
+ throw new Error("Session listing is not supported by the installed ai-sdk");
734
+ }
735
+ return sdk.listSessionsForBackend(backendType, options);
736
+ }
737
+
738
+ /**
739
+ * Pure aggregation behind the `list_backend_sessions` RPC: intersect the
740
+ * requested backends with what this daemon supports, list each backend's
741
+ * sessions, and merge them newest-first. A single backend failing lands in
742
+ * `errors` without affecting the others. Exported so tests can drive it with
743
+ * a stubbed `listSessions`.
744
+ */
745
+ export async function collectBackendSessions({
746
+ requestedBackends = null,
747
+ supportedBackends = [],
748
+ limit,
749
+ listSessions = listSessionsForBackendFromSdk,
750
+ } = {}) {
751
+ const numericLimit = Number(limit);
752
+ const effectiveLimit =
753
+ Number.isFinite(numericLimit) && numericLimit > 0
754
+ ? Math.min(Math.floor(numericLimit), MAX_BACKEND_SESSION_LIMIT)
755
+ : DEFAULT_BACKEND_SESSION_LIMIT;
756
+ const listableBackends = LISTABLE_SESSION_BACKENDS.filter((backend) => supportedBackends.includes(backend));
757
+ const errors = [];
758
+ let backends;
759
+ if (Array.isArray(requestedBackends)) {
760
+ backends = [];
761
+ for (const raw of requestedBackends) {
762
+ const backend = typeof raw === "string" ? raw.trim().toLowerCase() : "";
763
+ if (!backend || backends.includes(backend)) {
764
+ continue;
765
+ }
766
+ if (!listableBackends.includes(backend)) {
767
+ errors.push({ backend, message: `Backend "${backend}" does not support session listing on this daemon` });
768
+ continue;
769
+ }
770
+ backends.push(backend);
771
+ }
772
+ } else {
773
+ backends = listableBackends;
774
+ }
775
+ const sessions = [];
776
+ for (const backend of backends) {
777
+ try {
778
+ const listed = await listSessions(backend, { limit: effectiveLimit });
779
+ for (const entry of Array.isArray(listed) ? listed : []) {
780
+ const sessionId = normalizeOptionalString(entry?.sessionId);
781
+ if (!sessionId) {
782
+ continue;
783
+ }
784
+ sessions.push({
785
+ backend,
786
+ session_id: sessionId,
787
+ session_file_path: normalizeOptionalString(entry?.sessionFilePath),
788
+ cwd: normalizeOptionalString(entry?.cwd),
789
+ title: normalizeOptionalString(entry?.title),
790
+ updated_at: Number.isFinite(entry?.updatedAt) ? new Date(entry.updatedAt).toISOString() : null,
791
+ });
792
+ }
793
+ } catch (error) {
794
+ errors.push({ backend, message: error?.message || String(error) });
795
+ }
796
+ }
797
+ // ISO 8601 strings compare lexicographically; entries without a timestamp
798
+ // sort last.
799
+ sessions.sort((a, b) => (b.updated_at || "").localeCompare(a.updated_at || ""));
800
+ return { sessions, errors };
801
+ }
802
+
679
803
  function normalizeBooleanFlag(value) {
680
804
  if (typeof value === "boolean") {
681
805
  return value;
@@ -1062,6 +1186,10 @@ export function startDaemon(config = {}, deps = {}) {
1062
1186
  // path would cost function without removing reach (RFC 0034 makes the same
1063
1187
  // argument). An owner who wants it off can set `remote_exec: false`.
1064
1188
  const remoteExecEnabled = getRemoteExecEnabled(userConfig);
1189
+ // Same reasoning for file transfer, with one addition: when the share scoped
1190
+ // the guest to a root, transfers are confined to it (`isPathInsideGuestRoot`,
1191
+ // a lexical check — misuse prevention, not a security boundary).
1192
+ const remoteFileEnabled = getRemoteFileEnabled(userConfig);
1065
1193
 
1066
1194
  // Get allow_cli_list from config
1067
1195
  const RAW_ALLOW_CLI_LIST = getRawAllowCliList(userConfig);
@@ -3663,6 +3791,7 @@ export function startDaemon(config = {}, deps = {}) {
3663
3791
  "restart_daemon",
3664
3792
  "refresh_session_inplace",
3665
3793
  "task_attachments_v1",
3794
+ "backend_session_list",
3666
3795
  CUSTOM_COMMANDS_CAPABILITY,
3667
3796
  UPDATE_DAEMON_CAPABILITY,
3668
3797
  ];
@@ -3671,6 +3800,11 @@ export function startDaemon(config = {}, deps = {}) {
3671
3800
  } else {
3672
3801
  log("[remote-exec] Disabled by config (remote_exec: false); capability not advertised");
3673
3802
  }
3803
+ if (remoteFileEnabled) {
3804
+ advertisedCapabilities.push(REMOTE_FILE_CAPABILITY);
3805
+ } else {
3806
+ log("[remote-file] Disabled by config (remote_file: false); capability not advertised");
3807
+ }
3674
3808
  if (ptyTaskCapabilityEnabled) {
3675
3809
  advertisedCapabilities.push("pty_task", "terminal_snapshot");
3676
3810
  }
@@ -3738,6 +3872,13 @@ export function startDaemon(config = {}, deps = {}) {
3738
3872
  const remoteExecHandlers = remoteExecEnabled
3739
3873
  ? createRemoteExecHandlers({ defaultWorkspace: homeDir })
3740
3874
  : null;
3875
+ const remoteFileHandlers = remoteFileEnabled
3876
+ ? createRemoteFileHandlers({
3877
+ config: sdkConfig,
3878
+ agentHost: AGENT_NAME,
3879
+ guestRoot: IS_GUEST_DAEMON ? GUEST_ROOT : null,
3880
+ })
3881
+ : null;
3741
3882
 
3742
3883
  const client = createWebSocketClient(sdkConfig, {
3743
3884
  extraHeaders,
@@ -6173,6 +6314,10 @@ export function startDaemon(config = {}, deps = {}) {
6173
6314
  void handleGetProjectAgents(event.payload);
6174
6315
  return;
6175
6316
  }
6317
+ if (event.type === "list_backend_sessions") {
6318
+ void handleListBackendSessions(event.payload);
6319
+ return;
6320
+ }
6176
6321
  if (event.type === "ai_manager_request") {
6177
6322
  // RFC 0035: reads are fine and genuinely useful to a guest (it needs to
6178
6323
  // know how much quota the borrowed machine has left). `switch_account` is
@@ -6237,6 +6382,50 @@ export function startDaemon(config = {}, deps = {}) {
6237
6382
  logError(`Unhandled remote_exec_request failure: ${error?.message || error}`);
6238
6383
  });
6239
6384
  }
6385
+ if (event.type === "remote_file_request") {
6386
+ // Same reason as remote exec: a transfer leaves no Task row and no
6387
+ // per-run log, so it would be invisible after a restart. Only the action,
6388
+ // the path and the byte count are recorded — never file contents, and
6389
+ // never the transfer token, because this log is collected by
6390
+ // `collect_logs`.
6391
+ const fileArgs = event?.payload?.args && typeof event.payload.args === "object" ? event.payload.args : {};
6392
+ log(
6393
+ `[remote-file] ${event?.payload?.action || "?"} path=${fileArgs.remotePath || fileArgs.remote_path || ""} ` +
6394
+ `bytes=${fileArgs.sizeBytes ?? fileArgs.size_bytes ?? "?"}`,
6395
+ );
6396
+ // Fail fast rather than letting the caller wait out its full timeout —
6397
+ // it has no other way to learn that this daemon will never answer.
6398
+ const rejectReason = !remoteFileHandlers
6399
+ ? "remote file transfer is disabled on this daemon (remote_file: false)"
6400
+ : daemonShuttingDown
6401
+ ? "daemon is shutting down"
6402
+ : "";
6403
+ if (rejectReason) {
6404
+ void client
6405
+ .sendJson({
6406
+ type: "remote_file_response",
6407
+ payload: {
6408
+ request_id: event?.payload?.request_id ? String(event.payload.request_id) : "",
6409
+ action: event?.payload?.action ? String(event.payload.action) : "",
6410
+ error: rejectReason,
6411
+ },
6412
+ })
6413
+ .catch(() => {});
6414
+ return;
6415
+ }
6416
+ handleRemoteFileRequest(client, remoteFileHandlers, event.payload)
6417
+ .then((response) => {
6418
+ if (response?.result) {
6419
+ log(
6420
+ `[remote-file] ${event?.payload?.action || "?"} ok path=${response.result.path || ""} ` +
6421
+ `bytes=${response.result.bytesWritten ?? response.result.sizeBytes ?? "?"}`,
6422
+ );
6423
+ }
6424
+ })
6425
+ .catch((error) => {
6426
+ logError(`Unhandled remote_file_request failure: ${error?.message || error}`);
6427
+ });
6428
+ }
6240
6429
  if (event.type === "restart_daemon") {
6241
6430
  void handleRestartDaemon(event.payload).catch((error) => {
6242
6431
  logError(`Unhandled restart_daemon failure: ${error?.message || error}`);
@@ -6551,6 +6740,42 @@ export function startDaemon(config = {}, deps = {}) {
6551
6740
  }
6552
6741
  }
6553
6742
 
6743
+ async function handleListBackendSessions(payload) {
6744
+ const requestId = payload?.request_id ? String(payload.request_id).trim() : "";
6745
+ if (!requestId) {
6746
+ logError(`Invalid list_backend_sessions payload: ${JSON.stringify(payload)}`);
6747
+ return;
6748
+ }
6749
+
6750
+ let result;
6751
+ try {
6752
+ result = await collectBackendSessions({
6753
+ requestedBackends: Array.isArray(payload?.backends) ? payload.backends : null,
6754
+ supportedBackends: SUPPORTED_BACKENDS,
6755
+ limit: payload?.limit,
6756
+ listSessions: deps.listBackendSessions,
6757
+ });
6758
+ } catch (error) {
6759
+ result = {
6760
+ sessions: [],
6761
+ errors: [{ backend: "*", message: `Failed to list backend sessions on daemon ${AGENT_NAME}: ${error?.message || error}` }],
6762
+ };
6763
+ }
6764
+
6765
+ try {
6766
+ await client.sendJson({
6767
+ type: "backend_sessions_listed",
6768
+ payload: {
6769
+ request_id: requestId,
6770
+ sessions: result.sessions,
6771
+ errors: result.errors,
6772
+ },
6773
+ });
6774
+ } catch (error) {
6775
+ logError(`Failed to report backend_sessions_listed for ${requestId}: ${error?.message || error}`);
6776
+ }
6777
+ }
6778
+
6554
6779
  function stopActiveTaskProcess(
6555
6780
  taskId,
6556
6781
  {
@@ -7125,6 +7350,49 @@ export function startDaemon(config = {}, deps = {}) {
7125
7350
  });
7126
7351
  }
7127
7352
 
7353
+ /**
7354
+ * Recover the workspace of a resumable session from the backend's own
7355
+ * session store. Used when neither a launch-config cwd nor a bound project
7356
+ * path is available -- the Default Project resuming a local session -- so the
7357
+ * task runs (and logs) where the conversation actually lives instead of an
7358
+ * unrelated scratch directory.
7359
+ *
7360
+ * Returns "" when the session cannot be located; callers keep their own
7361
+ * fallbacks. `runtimeBackend` short-circuits the alias lookup for callers
7362
+ * that already resolved it.
7363
+ */
7364
+ async function resolveSessionResumeCwd({ backendType, runtimeBackend = "", sessionId }) {
7365
+ const normalizedSessionId = typeof sessionId === "string" ? sessionId.trim() : "";
7366
+ const normalizedBackend = normalizeRuntimeBackendName(backendType);
7367
+ // opencode has no resumable local session store to look the cwd up in.
7368
+ if (!normalizedSessionId || !normalizedBackend || normalizedBackend === "opencode") {
7369
+ return "";
7370
+ }
7371
+ try {
7372
+ let resumeBackend = normalizeOptionalString(runtimeBackend);
7373
+ if (!resumeBackend) {
7374
+ const configuredBackend = await resolveConfiguredRuntimeBackend(normalizedBackend, ALLOW_CLI_LIST, {
7375
+ configFilePath: config.CONFIG_FILE,
7376
+ });
7377
+ resumeBackend = configuredBackend?.runtimeBackend ||
7378
+ await normalizeRuntimeBackendAlias(normalizedBackend, { configFilePath: config.CONFIG_FILE });
7379
+ }
7380
+ const resumeContext = await (deps.resolveResumeContext || resolveResumeContext)(
7381
+ resumeBackend,
7382
+ normalizedSessionId,
7383
+ {
7384
+ cwd: process.cwd(),
7385
+ configFilePath: config.CONFIG_FILE,
7386
+ },
7387
+ );
7388
+ return normalizeOptionalString(resumeContext?.cwd) || "";
7389
+ } catch {
7390
+ // Provider lookup failed (unknown id, unreadable store): let the caller
7391
+ // fall back rather than failing the task here.
7392
+ return "";
7393
+ }
7394
+ }
7395
+
7128
7396
  async function resolveRestartCwd({
7129
7397
  taskId,
7130
7398
  projectId,
@@ -7158,29 +7426,9 @@ export function startDaemon(config = {}, deps = {}) {
7158
7426
  return boundPath;
7159
7427
  }
7160
7428
 
7161
- const normalizedBackend = normalizeRuntimeBackendName(backendType);
7162
- const normalizedSessionId = typeof sessionId === "string" ? sessionId.trim() : "";
7163
- if (normalizedSessionId && normalizedBackend && normalizedBackend !== "opencode") {
7164
- try {
7165
- const configuredBackend = await resolveConfiguredRuntimeBackend(normalizedBackend, ALLOW_CLI_LIST, {
7166
- configFilePath: config.CONFIG_FILE,
7167
- });
7168
- const resumeBackend = configuredBackend?.runtimeBackend ||
7169
- await normalizeRuntimeBackendAlias(normalizedBackend, { configFilePath: config.CONFIG_FILE });
7170
- const resumeContext = await (deps.resolveResumeContext || resolveResumeContext)(
7171
- resumeBackend,
7172
- normalizedSessionId,
7173
- {
7174
- cwd: process.cwd(),
7175
- configFilePath: config.CONFIG_FILE,
7176
- },
7177
- );
7178
- if (typeof resumeContext?.cwd === "string" && resumeContext.cwd.trim()) {
7179
- return resumeContext.cwd.trim();
7180
- }
7181
- } catch {
7182
- // ignore provider-specific fallback failure here; we'll try the remaining fallbacks
7183
- }
7429
+ const sessionCwd = await resolveSessionResumeCwd({ backendType, sessionId });
7430
+ if (sessionCwd) {
7431
+ return sessionCwd;
7184
7432
  }
7185
7433
 
7186
7434
  const normalizedSessionPath =
@@ -7341,7 +7589,14 @@ export function startDaemon(config = {}, deps = {}) {
7341
7589
  launchConfig,
7342
7590
  })) ||
7343
7591
  normalizeOptionalString(launchConfig.cwd) ||
7344
- boundPath;
7592
+ boundPath ||
7593
+ (await resolveSessionResumeCwd({
7594
+ backendType: requestedBackend,
7595
+ runtimeBackend: effectiveBackend,
7596
+ sessionId:
7597
+ normalizeOptionalString(launchConfig?.resumeSessionId) ||
7598
+ normalizeOptionalString(launchConfig?.resume_session_id),
7599
+ }));
7345
7600
 
7346
7601
  if (resolvedTaskWorkspace) {
7347
7602
  taskDir = resolvedTaskWorkspace;
@@ -7940,6 +8195,31 @@ export function startDaemon(config = {}, deps = {}) {
7940
8195
  return;
7941
8196
  }
7942
8197
 
8198
+ if (!resolvedResumeCwd && isForkMode) {
8199
+ // A fork starts a brand-new session and receives its context as a URL, so
8200
+ // it does not need the source task's files. When nothing in the chain
8201
+ // resolves — the normal case for a cross-daemon fork, where the server
8202
+ // drops the source machine's paths and this daemon holds no local path
8203
+ // for the project — land in a fresh workspace dir exactly like
8204
+ // create_task does, instead of killing the successor on arrival.
8205
+ const now = new Date();
8206
+ // Keyed off the task id, not the daemon pid: the run timestamp only has
8207
+ // second granularity and this path never renames the dir afterwards
8208
+ // (create_task does), so two forks landing in the same second would
8209
+ // otherwise share one workspace AND one conductor.log.
8210
+ const taskSuffix =
8211
+ normalizedTargetTaskId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 8) || String(process.pid);
8212
+ resolvedResumeCwd = path.join(
8213
+ WORKSPACE_ROOT,
8214
+ formatWorkspaceDate(now),
8215
+ `${formatWorkspaceRunTimestamp(now)}_fork_${taskSuffix}`,
8216
+ );
8217
+ log(
8218
+ `No local workspace for project ${normalizedProjectId} on this daemon; ` +
8219
+ `forking task ${normalizedTargetTaskId} into ${resolvedResumeCwd}`,
8220
+ );
8221
+ }
8222
+
7943
8223
  if (!resolvedResumeCwd) {
7944
8224
  reportRestartFailure({
7945
8225
  taskId: normalizedTargetTaskId,
@@ -8409,6 +8689,9 @@ export function startDaemon(config = {}, deps = {}) {
8409
8689
  clearInterval(tmuxLivenessTimer);
8410
8690
  tmuxLivenessTimer = null;
8411
8691
  }
8692
+ // An in-flight transfer holds an open socket and a `.part` file; abort
8693
+ // them so shutdown is not gated on a multi-gigabyte download finishing.
8694
+ remoteFileHandlers?.abortAll();
8412
8695
  const activeProcessEntries = [...activeTaskProcesses.entries()];
8413
8696
  const activePtyEntries = [...activePtySessions.entries()];
8414
8697
  const activeEntries = [...activeProcessEntries, ...activePtyEntries];
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Directory transfer, built on the single-file path plus `tar`.
3
+ *
4
+ * `remote cp -r` packs a directory locally (or remotely), moves the one
5
+ * resulting tarball through the ordinary transfer, then unpacks it on the other
6
+ * side. No new protocol: the wire still only ever carries one file.
7
+ *
8
+ * The remote halves run through `sh -c` with the paths passed as **positional
9
+ * arguments**, never interpolated into the script text. A directory literally
10
+ * named `; rm -rf /` is therefore just an awkward directory name.
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { spawn } from "node:child_process";
17
+ import { randomUUID } from "node:crypto";
18
+
19
+ /** Where the target stages its tarball. Overridable for hosts without /tmp. */
20
+ export function remoteTempPath(env = process.env) {
21
+ const root = (env.CONDUCTOR_REMOTE_TMP || "/tmp").replace(/\/+$/, "") || "/tmp";
22
+ return `${root}/.conductor-rcp-${randomUUID()}.tgz`;
23
+ }
24
+
25
+ export function localTempPath() {
26
+ return path.join(os.tmpdir(), `.conductor-rcp-${randomUUID()}.tgz`);
27
+ }
28
+
29
+ /**
30
+ * Unpack `$1` into `$2`, following scp: an existing destination directory
31
+ * receives the source *inside* it; a missing one becomes the copy itself.
32
+ * `$3` is the source's basename, which is the top-level entry in the tarball.
33
+ */
34
+ export const EXTRACT_SCRIPT = `
35
+ set -e
36
+ tmp=$1; dest=$2; base=$3
37
+ if [ -d "$dest" ]; then
38
+ tar xzf "$tmp" -C "$dest"
39
+ else
40
+ parent=$(dirname "$dest")
41
+ mkdir -p "$parent"
42
+ # Unpack into a private staging dir, never straight into the parent: the
43
+ # tarball's top entry is the SOURCE's name, and an unrelated neighbour of
44
+ # the destination could already have it. Extracting in place would clobber
45
+ # that neighbour, and the rename below would then carry it off.
46
+ # Staging inside the parent keeps the final mv on one filesystem.
47
+ stage=$(mktemp -d "$parent/.conductor-rcp-XXXXXX")
48
+ tar xzf "$tmp" -C "$stage"
49
+ rm -rf "$dest"
50
+ mv "$stage/$base" "$dest"
51
+ rmdir "$stage"
52
+ fi
53
+ `.trim();
54
+
55
+ /** Pack directory `$1` into tarball `$2`, keeping `$1`'s own name inside. */
56
+ export const PACK_SCRIPT = `
57
+ set -e
58
+ src=$1; tmp=$2
59
+ if [ ! -d "$src" ]; then
60
+ echo "not a directory: $src" >&2
61
+ exit 1
62
+ fi
63
+ tar czf "$tmp" -C "$(dirname "$src")" "$(basename "$src")"
64
+ `.trim();
65
+
66
+ /** Remove the staged tarball. Never fails the transfer. */
67
+ export const CLEANUP_SCRIPT = 'rm -f "$1"';
68
+
69
+ /** `sh -c <script> sh <args...>` — the `sh` placeholder becomes $0. */
70
+ export function shellArgs(script, ...args) {
71
+ return ["-c", script, "sh", ...args];
72
+ }
73
+
74
+ function runLocal(command, args, { spawnFn = spawn } = {}) {
75
+ return new Promise((resolve, reject) => {
76
+ const child = spawnFn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
77
+ let stderr = "";
78
+ child.stderr?.on("data", (chunk) => {
79
+ // Bounded: a pathological tar could otherwise stream forever.
80
+ if (stderr.length < 8192) stderr += String(chunk);
81
+ });
82
+ child.on("error", (error) =>
83
+ reject(new Error(`failed to run ${command}: ${error.message}`)));
84
+ child.on("close", (code) => {
85
+ if (code === 0) return resolve();
86
+ reject(new Error(`${command} exited ${code}${stderr.trim() ? `: ${stderr.trim()}` : ""}`));
87
+ });
88
+ });
89
+ }
90
+
91
+ /** Pack a local directory, preserving its own name as the top-level entry. */
92
+ export async function packLocalDirectory(sourceDir, tarball, deps = {}) {
93
+ await runLocal(
94
+ "tar",
95
+ ["czf", tarball, "-C", path.dirname(sourceDir), path.basename(sourceDir)],
96
+ deps,
97
+ );
98
+ return tarball;
99
+ }
100
+
101
+ /** Unpack locally with the same scp semantics as `EXTRACT_SCRIPT`. */
102
+ export async function unpackLocalDirectory(tarball, destination, baseName, deps = {}) {
103
+ const fsp = deps.fsp || fs.promises;
104
+ let destStat = null;
105
+ try {
106
+ destStat = await fsp.stat(destination);
107
+ } catch {
108
+ destStat = null;
109
+ }
110
+
111
+ if (destStat?.isDirectory()) {
112
+ await runLocal("tar", ["xzf", tarball, "-C", destination], deps);
113
+ return path.join(destination, baseName);
114
+ }
115
+
116
+ const parent = path.dirname(destination);
117
+ await fsp.mkdir(parent, { recursive: true });
118
+ // Same reasoning as EXTRACT_SCRIPT: stage in a private directory so an
119
+ // existing neighbour that happens to share the source's name survives.
120
+ const stage = await fsp.mkdtemp(path.join(parent, ".conductor-rcp-"));
121
+ try {
122
+ await runLocal("tar", ["xzf", tarball, "-C", stage], deps);
123
+ await fsp.rm(destination, { recursive: true, force: true });
124
+ await fsp.rename(path.join(stage, baseName), destination);
125
+ } finally {
126
+ await fsp.rm(stage, { recursive: true, force: true }).catch(() => {});
127
+ }
128
+ return destination;
129
+ }