Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
546 lines
21 KiB
JavaScript
546 lines
21 KiB
JavaScript
/* eslint-disable no-undef */
|
|
function createSftpHandlerApi(ctx) {
|
|
with (ctx) {
|
|
function getSessionSftpEncodingStateKey(chatSessionId, sessionId) {
|
|
if (!chatSessionId || !sessionId) return null;
|
|
return `chat:${chatSessionId}:session:${sessionId}`;
|
|
}
|
|
|
|
function getWorkerManager() {
|
|
return typeof terminalWorkerManager !== "undefined" ? terminalWorkerManager : null;
|
|
}
|
|
|
|
function getMainSessions() {
|
|
return typeof sessions !== "undefined" ? sessions : null;
|
|
}
|
|
|
|
function getStableTransferHostId(params) {
|
|
if (typeof params?.hostId === "string" && params.hostId) return params.hostId;
|
|
const mainHostId = getMainSessions()?.get?.(params?.sessionId)?.hostId;
|
|
if (typeof mainHostId === "string" && mainHostId) return mainHostId;
|
|
const workerHostId = getWorkerManager()?.getSessionHostId?.(params?.sessionId);
|
|
if (typeof workerHostId === "string" && workerHostId) return workerHostId;
|
|
return typeof params?.sessionId === "string" && params.sessionId ? params.sessionId : undefined;
|
|
}
|
|
|
|
function shouldProxySessionBackedSftpToWorker(params) {
|
|
if (!params?.sessionId) return false;
|
|
const manager = getWorkerManager();
|
|
if (!manager?.request) return false;
|
|
const mainSessions = getMainSessions();
|
|
return !mainSessions?.get?.(params.sessionId);
|
|
}
|
|
|
|
function waitForWorkerSftpRequest(requestPromise, options = {}) {
|
|
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
|
|
? options.timeoutMs
|
|
: 0;
|
|
if (!timeoutMs) return requestPromise;
|
|
|
|
let timer = null;
|
|
const timeoutPromise = new Promise((_, reject) => {
|
|
timer = setTimeout(() => {
|
|
reject(new Error(`${options.operationName || "SFTP operation"} timed out after ${timeoutMs}ms`));
|
|
}, timeoutMs);
|
|
});
|
|
return Promise.race([requestPromise, timeoutPromise]).finally(() => {
|
|
if (timer) clearTimeout(timer);
|
|
});
|
|
}
|
|
|
|
function requestWorkerSftp(channel, payload, options = {}) {
|
|
const manager = getWorkerManager();
|
|
if (!manager?.request) {
|
|
return Promise.reject(new Error("Terminal worker is unavailable"));
|
|
}
|
|
return waitForWorkerSftpRequest(manager.request(channel, payload, {}), options);
|
|
}
|
|
|
|
async function withWorkerSessionBackedSftp(params, workerChannel, options = {}) {
|
|
if (!workerChannel) throw new Error("Worker SFTP channel is required");
|
|
const chatSessionId = typeof params?.chatSessionId === "string" && params.chatSessionId ? params.chatSessionId : null;
|
|
const encodingStateKey = getSessionSftpEncodingStateKey(chatSessionId, params.sessionId);
|
|
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 0;
|
|
const operationName = options.operationName || "SFTP operation";
|
|
let sftpId = null;
|
|
let pendingOpenPromise = null;
|
|
let boundedOpenPromise = null;
|
|
let closePromise = null;
|
|
let closeRequested = false;
|
|
let cancellationError = null;
|
|
|
|
const closeKnownSftpHandle = () => {
|
|
if (!sftpId) return Promise.resolve();
|
|
if (!closePromise) {
|
|
closePromise = requestWorkerSftp("netcatty:sftp:close", { sftpId, encodingStateKey });
|
|
}
|
|
return closePromise;
|
|
};
|
|
const closeSftpHandle = async () => {
|
|
closeRequested = true;
|
|
if (!sftpId && boundedOpenPromise) {
|
|
try {
|
|
const opened = await boundedOpenPromise;
|
|
sftpId = opened?.sftpId || null;
|
|
} catch {
|
|
// Do not let an unresponsive worker open block cancellation. If it
|
|
// eventually succeeds, the late-result handler below closes it.
|
|
}
|
|
}
|
|
return closeKnownSftpHandle();
|
|
};
|
|
const unregisterSftpOp = registerSftpOp(chatSessionId, params.sessionId, () => {
|
|
if (!cancellationError) {
|
|
cancellationError = new Error("Cancelled");
|
|
}
|
|
return closeSftpHandle().catch(() => {
|
|
// Ignore close failures while cancelling a worker-backed SFTP handle.
|
|
});
|
|
});
|
|
|
|
try {
|
|
const manager = getWorkerManager();
|
|
pendingOpenPromise = manager.request("netcatty:sftp:openForSession", {
|
|
sessionId: params.sessionId,
|
|
encodingStateKey,
|
|
timeoutMs,
|
|
}, {});
|
|
boundedOpenPromise = waitForWorkerSftpRequest(pendingOpenPromise, { timeoutMs, operationName });
|
|
void pendingOpenPromise.then((lateOpened) => {
|
|
if (!sftpId) sftpId = lateOpened?.sftpId || null;
|
|
if (closeRequested) return closeKnownSftpHandle();
|
|
return undefined;
|
|
}).catch(() => {
|
|
// The bounded request reports open failures to the active operation.
|
|
});
|
|
const opened = await boundedOpenPromise;
|
|
sftpId = opened?.sftpId;
|
|
if (!sftpId) throw new Error("Failed to open session-backed SFTP handle");
|
|
if (cancellationError) throw cancellationError;
|
|
|
|
const { abortSignal: _abortSignal, ...workerParams } = params || {};
|
|
const workerPayload = options.buildWorkerPayload
|
|
? options.buildWorkerPayload(workerParams, sftpId)
|
|
: { ...workerParams, sftpId, timeoutMs };
|
|
const value = await requestWorkerSftp(workerChannel, workerPayload, { timeoutMs, operationName });
|
|
if (cancellationError) throw cancellationError;
|
|
return value;
|
|
} finally {
|
|
unregisterSftpOp();
|
|
try {
|
|
await closeSftpHandle();
|
|
} catch {
|
|
// Ignore close failures for one-off worker-backed SFTP handles.
|
|
}
|
|
}
|
|
}
|
|
|
|
async function withSessionBackedSftp(params, action, options = {}) {
|
|
if (!params?.sessionId) throw new Error("sessionId is required");
|
|
if (shouldProxySessionBackedSftpToWorker(params) && options.workerChannel) {
|
|
return withWorkerSessionBackedSftp(params, options.workerChannel, options);
|
|
}
|
|
const chatSessionId = typeof params?.chatSessionId === "string" && params.chatSessionId ? params.chatSessionId : null;
|
|
const encodingStateKey = getSessionSftpEncodingStateKey(chatSessionId, params.sessionId);
|
|
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 0;
|
|
const cancelCleanupGraceMs = Number.isFinite(options.cancelCleanupGraceMs) && options.cancelCleanupGraceMs >= 0
|
|
? options.cancelCleanupGraceMs
|
|
: 1000;
|
|
const operationName = options.operationName || "SFTP operation";
|
|
const abortController = new AbortController();
|
|
let sftpId = null;
|
|
let timeoutId = null;
|
|
let forceCloseTimer = null;
|
|
let closeRequested = false;
|
|
let closePromise = null;
|
|
let cancellationError = null;
|
|
let timeoutError = null;
|
|
const closeSftpHandle = () => {
|
|
if (!sftpId) {
|
|
return Promise.resolve();
|
|
}
|
|
if (!closePromise) {
|
|
closePromise = Promise.resolve().then(() => sftpBridge.closeSftp(null, { sftpId, encodingStateKey }));
|
|
}
|
|
return closePromise;
|
|
};
|
|
const closeSftpInBackground = () => {
|
|
if (closeRequested) return;
|
|
closeRequested = true;
|
|
void closeSftpHandle().catch(() => {
|
|
// Ignore close failures while cleaning up a cancelled or timed-out handle.
|
|
});
|
|
};
|
|
const requestAbort = (err) => {
|
|
if (!abortController.signal.aborted) {
|
|
abortController.abort(err);
|
|
}
|
|
if (!forceCloseTimer && !closeRequested) {
|
|
forceCloseTimer = setTimeout(() => {
|
|
forceCloseTimer = null;
|
|
closeSftpInBackground();
|
|
}, cancelCleanupGraceMs);
|
|
}
|
|
};
|
|
const unregisterSftpOp = registerSftpOp(chatSessionId, params.sessionId, () => {
|
|
if (!cancellationError) {
|
|
cancellationError = new Error("Cancelled");
|
|
}
|
|
requestAbort(cancellationError);
|
|
closeRequested = true;
|
|
return closeSftpHandle().catch(() => {
|
|
// Ignore close failures while cancelling the SFTP operation.
|
|
});
|
|
});
|
|
try {
|
|
if (timeoutMs) {
|
|
timeoutId = setTimeout(() => {
|
|
if (!timeoutError) {
|
|
timeoutError = new Error(`${operationName} timed out after ${timeoutMs}ms`);
|
|
}
|
|
requestAbort(timeoutError);
|
|
}, timeoutMs);
|
|
}
|
|
|
|
const opened = await sftpBridge.openSftpForSession(null, {
|
|
sessionId: params.sessionId,
|
|
encodingStateKey,
|
|
abortSignal: abortController.signal,
|
|
timeoutMs,
|
|
});
|
|
sftpId = opened?.sftpId;
|
|
if (!sftpId) throw new Error("Failed to open session-backed SFTP handle");
|
|
if (timeoutError) {
|
|
throw timeoutError;
|
|
}
|
|
if (cancellationError) {
|
|
throw cancellationError;
|
|
}
|
|
|
|
const payload = {
|
|
...params,
|
|
sftpId,
|
|
abortSignal: abortController.signal,
|
|
timeoutMs,
|
|
};
|
|
const value = await Promise.resolve().then(() => action(payload));
|
|
if (timeoutError) {
|
|
throw timeoutError;
|
|
}
|
|
if (cancellationError) {
|
|
throw cancellationError;
|
|
}
|
|
return value;
|
|
} catch (err) {
|
|
if (timeoutError) {
|
|
throw timeoutError;
|
|
}
|
|
if (cancellationError) {
|
|
throw cancellationError;
|
|
}
|
|
throw err;
|
|
} finally {
|
|
unregisterSftpOp();
|
|
if (timeoutId) clearTimeout(timeoutId);
|
|
if (forceCloseTimer) {
|
|
clearTimeout(forceCloseTimer);
|
|
forceCloseTimer = null;
|
|
}
|
|
try {
|
|
await closeSftpHandle();
|
|
} catch {
|
|
// Ignore close failures for one-off internal SFTP handles.
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleSftpList(params) {
|
|
const entries = await withSessionBackedSftp(
|
|
params,
|
|
(payload) => sftpBridge.listSftp(null, payload),
|
|
{ timeoutMs: commandTimeoutMs, operationName: "SFTP list", workerChannel: "netcatty:sftp:list" },
|
|
);
|
|
return { ok: true, entries };
|
|
}
|
|
|
|
async function handleSftpRead(params) {
|
|
if (!params?.path) throw new Error("path is required");
|
|
const content = await withSessionBackedSftp(
|
|
params,
|
|
(payload) => sftpBridge.readSftp(null, payload),
|
|
{ timeoutMs: commandTimeoutMs, operationName: "SFTP read", workerChannel: "netcatty:sftp:read" },
|
|
);
|
|
return { ok: true, path: params.path, content };
|
|
}
|
|
|
|
async function handleSftpWrite(params) {
|
|
if (!params?.path) throw new Error("path is required");
|
|
if (typeof params?.content !== "string") throw new Error("content is required");
|
|
await withSessionBackedSftp(
|
|
params,
|
|
(payload) => sftpBridge.writeSftp(null, payload),
|
|
{ timeoutMs: commandTimeoutMs, operationName: "SFTP write", workerChannel: "netcatty:sftp:write" },
|
|
);
|
|
return { ok: true, path: params.path };
|
|
}
|
|
|
|
async function handleSftpDownload(params) {
|
|
if (!params?.remotePath || !params?.localPath) {
|
|
throw new Error("remotePath and localPath are required");
|
|
}
|
|
const transferId = createTransferId();
|
|
const sourceHostId = getStableTransferHostId(params);
|
|
reportTransferEvent({
|
|
type: "queued", transferId, origin: "agent", background: true,
|
|
direction: "download", sourcePath: params.remotePath, targetPath: params.localPath,
|
|
sessionId: params.sessionId, startedAt: Date.now(),
|
|
sourceHostId,
|
|
});
|
|
try {
|
|
const sender = {
|
|
send(channel, payload) {
|
|
if (channel === "netcatty:transfer:progress") {
|
|
reportTransferEvent({ type: "progress", ...payload });
|
|
} else if (channel === "netcatty:transfer:started") {
|
|
reportTransferEvent({ type: "started", ...payload });
|
|
} else if (channel === "netcatty:transfer:queued") {
|
|
reportTransferEvent({ type: "queued", ...payload });
|
|
} else if (channel === "netcatty:transfer:paused") {
|
|
reportTransferEvent({ type: "paused", ...payload });
|
|
} else if (channel === "netcatty:transfer:cancelled") {
|
|
reportTransferEvent({ type: "cancelled", ...payload, endedAt: Date.now() });
|
|
}
|
|
},
|
|
};
|
|
const useOuterAdmission = !!(
|
|
shouldProxySessionBackedSftpToWorker(params) && transferBridge?.runAdmittedTransfer
|
|
);
|
|
const runDownload = (skipAdmission) => withSessionBackedSftp(
|
|
params,
|
|
(payload) => transferBridge
|
|
? transferBridge.startTransfer({ sender }, {
|
|
transferId,
|
|
sourcePath: params.remotePath,
|
|
targetPath: params.localPath,
|
|
sourceType: "sftp",
|
|
targetType: "local",
|
|
sourceSftpId: payload.sftpId,
|
|
sourceHostId,
|
|
resumable: true,
|
|
globalConcurrency: transferBridge.getGlobalTransferConcurrency?.(),
|
|
// Only skip when outer runAdmittedTransfer already owns the slot.
|
|
skipAdmission: skipAdmission === true,
|
|
})
|
|
: sftpBridge.downloadSftpToLocal(null, payload),
|
|
{
|
|
timeoutMs: commandTimeoutMs,
|
|
operationName: "SFTP download",
|
|
workerChannel: transferBridge ? "netcatty:transfer:start" : "netcatty:sftp:downloadToLocal",
|
|
buildWorkerPayload: transferBridge ? (_workerParams, sftpId) => ({
|
|
transferId,
|
|
sourcePath: params.remotePath,
|
|
targetPath: params.localPath,
|
|
sourceType: "sftp",
|
|
targetType: "local",
|
|
sourceSftpId: sftpId,
|
|
sourceHostId,
|
|
resumable: true,
|
|
skipAdmission: true,
|
|
}) : undefined,
|
|
},
|
|
);
|
|
const result = await (
|
|
useOuterAdmission
|
|
? transferBridge.runAdmittedTransfer(
|
|
{ sender },
|
|
{ transferId, sourceHostId, globalConcurrency: transferBridge.getGlobalTransferConcurrency?.() },
|
|
undefined,
|
|
() => runDownload(true),
|
|
)
|
|
: runDownload(false)
|
|
);
|
|
if (result?.cancelled || result?.error === "Transfer cancelled") {
|
|
reportTransferEvent({ type: "cancelled", transferId, endedAt: Date.now() });
|
|
return { ok: false, cancelled: true, transferId };
|
|
}
|
|
if (result?.error) throw new Error(result.error);
|
|
reportTransferEvent({ type: "completed", transferId, endedAt: Date.now() });
|
|
return { ok: true, transferId, ...result };
|
|
} catch (error) {
|
|
reportTransferEvent({ type: "failed", transferId, endedAt: Date.now(), error: error?.message || String(error) });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function handleSftpUpload(params) {
|
|
if (!params?.remotePath || !params?.localPath) {
|
|
throw new Error("remotePath and localPath are required");
|
|
}
|
|
const transferId = createTransferId();
|
|
const targetHostId = getStableTransferHostId(params);
|
|
reportTransferEvent({
|
|
type: "queued", transferId, origin: "agent", background: true,
|
|
direction: "upload", sourcePath: params.localPath, targetPath: params.remotePath,
|
|
sessionId: params.sessionId, startedAt: Date.now(),
|
|
targetHostId,
|
|
});
|
|
try {
|
|
const sender = {
|
|
send(channel, payload) {
|
|
if (channel === "netcatty:transfer:progress") {
|
|
reportTransferEvent({ type: "progress", ...payload });
|
|
} else if (channel === "netcatty:transfer:started") {
|
|
reportTransferEvent({ type: "started", ...payload });
|
|
} else if (channel === "netcatty:transfer:queued") {
|
|
reportTransferEvent({ type: "queued", ...payload });
|
|
} else if (channel === "netcatty:transfer:paused") {
|
|
reportTransferEvent({ type: "paused", ...payload });
|
|
} else if (channel === "netcatty:transfer:cancelled") {
|
|
reportTransferEvent({ type: "cancelled", ...payload, endedAt: Date.now() });
|
|
}
|
|
},
|
|
};
|
|
const useOuterAdmissionUpload = !!(
|
|
shouldProxySessionBackedSftpToWorker(params) && transferBridge?.runAdmittedTransfer
|
|
);
|
|
const runUpload = (skipAdmission) => withSessionBackedSftp(
|
|
params,
|
|
(payload) => transferBridge
|
|
? transferBridge.startTransfer({ sender }, {
|
|
transferId,
|
|
sourcePath: params.localPath,
|
|
targetPath: params.remotePath,
|
|
sourceType: "local",
|
|
targetType: "sftp",
|
|
targetSftpId: payload.sftpId,
|
|
targetHostId,
|
|
resumable: true,
|
|
globalConcurrency: transferBridge.getGlobalTransferConcurrency?.(),
|
|
// Only skip when outer runAdmittedTransfer already owns the slot.
|
|
skipAdmission: skipAdmission === true,
|
|
})
|
|
: sftpBridge.uploadLocalToSftp(null, payload),
|
|
{
|
|
timeoutMs: commandTimeoutMs,
|
|
operationName: "SFTP upload",
|
|
workerChannel: transferBridge ? "netcatty:transfer:start" : "netcatty:sftp:uploadLocal",
|
|
buildWorkerPayload: transferBridge ? (_workerParams, sftpId) => ({
|
|
transferId,
|
|
sourcePath: params.localPath,
|
|
targetPath: params.remotePath,
|
|
sourceType: "local",
|
|
targetType: "sftp",
|
|
targetSftpId: sftpId,
|
|
targetHostId,
|
|
resumable: true,
|
|
skipAdmission: true,
|
|
}) : undefined,
|
|
},
|
|
);
|
|
const result = await (
|
|
useOuterAdmissionUpload
|
|
? transferBridge.runAdmittedTransfer(
|
|
{ sender },
|
|
{ transferId, targetHostId, globalConcurrency: transferBridge.getGlobalTransferConcurrency?.() },
|
|
undefined,
|
|
() => runUpload(true),
|
|
)
|
|
: runUpload(false)
|
|
);
|
|
if (result?.cancelled || result?.error === "Transfer cancelled") {
|
|
reportTransferEvent({ type: "cancelled", transferId, endedAt: Date.now() });
|
|
return { ok: false, cancelled: true, transferId };
|
|
}
|
|
if (result?.error) throw new Error(result.error);
|
|
reportTransferEvent({ type: "completed", transferId, endedAt: Date.now() });
|
|
return { ok: true, transferId, ...result };
|
|
} catch (error) {
|
|
reportTransferEvent({ type: "failed", transferId, endedAt: Date.now(), error: error?.message || String(error) });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function handleSftpMkdir(params) {
|
|
if (!params?.path) throw new Error("path is required");
|
|
await withSessionBackedSftp(
|
|
params,
|
|
(payload) => sftpBridge.mkdirSftp(null, payload),
|
|
{ timeoutMs: commandTimeoutMs, operationName: "SFTP mkdir", workerChannel: "netcatty:sftp:mkdir" },
|
|
);
|
|
return { ok: true, path: params.path };
|
|
}
|
|
|
|
async function handleSftpDelete(params) {
|
|
if (!params?.path) throw new Error("path is required");
|
|
await withSessionBackedSftp(
|
|
params,
|
|
(payload) => sftpBridge.deleteSftp(null, payload),
|
|
{ timeoutMs: commandTimeoutMs, operationName: "SFTP delete", workerChannel: "netcatty:sftp:delete" },
|
|
);
|
|
return { ok: true, path: params.path };
|
|
}
|
|
|
|
async function handleSftpRename(params) {
|
|
if (!params?.oldPath || !params?.newPath) {
|
|
throw new Error("oldPath and newPath are required");
|
|
}
|
|
await withSessionBackedSftp(
|
|
params,
|
|
(payload) => sftpBridge.renameSftp(null, payload),
|
|
{ timeoutMs: commandTimeoutMs, operationName: "SFTP rename", workerChannel: "netcatty:sftp:rename" },
|
|
);
|
|
return { ok: true, oldPath: params.oldPath, newPath: params.newPath };
|
|
}
|
|
|
|
async function handleSftpStat(params) {
|
|
if (!params?.path) throw new Error("path is required");
|
|
const stat = await withSessionBackedSftp(
|
|
params,
|
|
(payload) => sftpBridge.statSftp(null, payload),
|
|
{ timeoutMs: commandTimeoutMs, operationName: "SFTP stat", workerChannel: "netcatty:sftp:stat" },
|
|
);
|
|
return { ok: true, stat };
|
|
}
|
|
|
|
async function handleSftpChmod(params) {
|
|
if (!params?.path || !params?.mode) throw new Error("path and mode are required");
|
|
await withSessionBackedSftp(
|
|
params,
|
|
(payload) => sftpBridge.chmodSftp(null, payload),
|
|
{ timeoutMs: commandTimeoutMs, operationName: "SFTP chmod", workerChannel: "netcatty:sftp:chmod" },
|
|
);
|
|
return { ok: true, path: params.path, mode: params.mode };
|
|
}
|
|
|
|
async function handleSftpHome(params) {
|
|
const result = await withSessionBackedSftp(
|
|
params,
|
|
(payload) => sftpBridge.getSftpHomeDir(null, payload),
|
|
{ timeoutMs: commandTimeoutMs, operationName: "SFTP home", workerChannel: "netcatty:sftp:homeDir" },
|
|
);
|
|
if (!result?.success) {
|
|
throw new Error(result?.error || "Could not determine home directory");
|
|
}
|
|
return { ok: true, homeDir: result.homeDir };
|
|
}
|
|
|
|
return {
|
|
getSessionSftpEncodingStateKey,
|
|
withSessionBackedSftp,
|
|
handleSftpList,
|
|
handleSftpRead,
|
|
handleSftpWrite,
|
|
handleSftpDownload,
|
|
handleSftpUpload,
|
|
handleSftpMkdir,
|
|
handleSftpDelete,
|
|
handleSftpRename,
|
|
handleSftpStat,
|
|
handleSftpChmod,
|
|
handleSftpHome,
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = { createSftpHandlerApi };
|