Published source
Download source ZIP
CH-J Server Manager
Browse directories and files for a specific application release.
Source is provided under the CH-J Proprietary Software License 1.14. Its availability does not change the license terms or grant additional rights.
1
"use strict";3
const crypto = require("node:crypto");4
const fs = require("node:fs");5
const os = require("node:os");6
const path = require("node:path");7
const archiver = require("archiver");9
const MAX_TEXT_BYTES = 25 * 1024 * 1024;10
const MAX_TRANSFER_BYTES = 16 * 1024 * 1024 * 1024;11
const MAX_BATCH_ENTRIES = 10000;12
const MAX_SELECTED_ENTRIES = 100;13
const ARCHIVE_FORMATS = new Set(["zip", "tar", "tar.gz"]);14
const DEFINITE_EDITOR_FAILURES = new Set([15
"FILE_CONFLICT", "FILE_PERMISSION_DENIED", "FILE_NOT_FOUND", "FILE_UNSAFE_PATH", "FILE_UNSAFE_TYPE_OR_LINK",16
"FILE_UNSAFE_WORKSPACE", "FILE_UNSAFE_PRIVILEGED_DIRECTORY", "FILE_UPLOAD_INCOMPLETE", "FILE_TOO_LARGE",17
"FILE_METADATA_UNSUPPORTED", "FILE_REMOTE_IO_FAILED", "FILE_SUDO_FAILED", "FILE_DEPENDENCY_UNAVAILABLE",18
"FILE_SUDO_PASSWORD_INVALID", "FILE_SUDO_AUTHORIZATION_REQUIRED", "FILE_LINUX_REQUIRED"19
]);21
function normalizeRemotePath(value) {22
const raw = String(value || "").trim();23
if (!raw || raw.length > 4096 || !raw.startsWith("/") || /[\0\r\n]/.test(raw)) throw new Error("Remote path must be an absolute path.");24
return path.posix.normalize(raw);25
}27
function call(sftp, method, ...args) {28
return new Promise((resolve, reject) => {29
sftp[method](...args, (error, result) => error ? reject(error) : resolve(result));30
});31
}33
function safeEntryName(value) {34
const name = String(value || "");35
if (!name || name === "." || name === ".." || /[\/\\\0]/.test(name)) throw new Error("Remote entry name cannot be represented safely on the local filesystem.");36
return name;37
}39
function selectedPaths(result) {40
if (!result || result.canceled) return [];41
const values = Array.isArray(result.paths) ? result.paths : result.path ? [result.path] : [];42
return [...new Set(values.map(String).filter(Boolean))].slice(0, MAX_SELECTED_ENTRIES + 1);43
}45
class RemoteFileService {46
constructor(options) {47
this.sessionManager = options.sessionManager;48
this.selectUploadPath = options.selectUploadPath;49
this.selectDownloadPath = options.selectDownloadPath;50
this.selectDownloadDirectory = options.selectDownloadDirectory;51
this.selectArchivePath = options.selectArchivePath;52
this.logger = options.logger;53
this.edits = new Map();54
this.legacyEdits = new Map();55
}57
async list(sessionId, remotePath) {58
const target = normalizeRemotePath(remotePath || "/");59
return this._withSftp(sessionId, async (sftp) => {60
const entries = await call(sftp, "readdir", target);61
return (entries || []).filter((entry) => entry.filename && entry.filename !== "." && entry.filename !== "..").map((entry) => {62
const attrs = entry.attrs || {};63
const isDirectory = typeof attrs.isDirectory === "function" && attrs.isDirectory();64
const isSymbolicLink = typeof attrs.isSymbolicLink === "function" && attrs.isSymbolicLink();65
return {66
name: String(entry.filename),67
path: path.posix.join(target, entry.filename),68
type: isDirectory ? "directory" : isSymbolicLink ? "symlink" : "file",69
size: Math.max(0, Number(attrs.size) || 0),70
modifiedAt: Number(attrs.mtime) > 0 ? new Date(Number(attrs.mtime) * 1000).toISOString() : null,71
mode: Number(attrs.mode) || 072
};73
}).sort((left, right) => {74
const order = { directory: 0, file: 1, symlink: 2 };75
return order[left.type] === order[right.type] ? left.name.localeCompare(right.name) : order[left.type] - order[right.type];76
});77
});78
}80
_editorPath(value) {81
const raw = String(value || "");82
if (!raw || raw.length > 4096 || !raw.startsWith("/") || /[\0\r\n]/.test(raw)) throw new Error("Remote path must be an absolute path.");83
if (raw.split("/").some((part) => part === "." || part === "..")) throw new Error("Editor paths cannot contain traversal components.");84
return path.posix.normalize(raw);85
}87
_identity(sessionId) {88
const session = this.sessionManager.list().find((entry) => entry.sessionId === sessionId && entry.state === "connected");89
if (!session) throw new Error("The editor session is not connected.");90
return JSON.stringify([sessionId, session.profileId, session.host, session.port, session.username]);91
}93
async readText(sessionId, remotePath, options = {}, owner = "core") {94
const target = this._editorPath(remotePath);95
const identity = this._identity(sessionId);96
const value = await this.sessionManager.remoteEditor(sessionId, { operation: "read", path: target }, options);97
const data = Buffer.from(value.data, "base64");98
if (data.length > MAX_TEXT_BYTES) throw new Error(`Text editor limit is ${MAX_TEXT_BYTES} bytes.`);99
let text;100
try { text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(data); }101
catch { throw new Error("The selected file is not valid UTF-8 text."); }102
if (crypto.createHash("sha256").update(data).digest("hex") !== value.baseline.hash) throw new Error("Remote editor content verification failed.");103
if (this.edits.size >= 1000) throw new Error("Close unused editor documents before opening more files.");104
const editId = crypto.randomBytes(16).toString("hex");105
this.edits.set(editId, { target, identity, owner, baseline: value.baseline, busy: false, uncertain: false });106
const key = JSON.stringify([owner, identity, target]);107
// A legacy call has no document identifier. Once two documents are open,108
// choosing either baseline would silently defeat conflict detection.109
this.legacyEdits.set(key, this.legacyEdits.has(key) ? null : editId);110
return { path: target, text, size: data.length, editId, baselineHash: value.baseline.hash, metadata: value.baseline,111
modifiedAt: new Date(Number(BigInt(value.baseline.mtimeNs) / 1000000n)).toISOString() };112
}114
async writeText(sessionId, remotePath, text, options = {}, owner = "core") {115
const target = this._editorPath(remotePath);116
const data = Buffer.from(String(text ?? ""), "utf8");117
if (data.length > MAX_TEXT_BYTES) throw new Error(`Text editor limit is ${MAX_TEXT_BYTES} bytes.`);118
const identity = this._identity(sessionId);119
const legacyId = this.legacyEdits.get(JSON.stringify([owner, identity, target]));120
if (!options.editId && legacyId === null) {121
const error = new Error("FILE_EDIT_ID_REQUIRED: Multiple documents are open for this path; pass the editId returned by readText.");122
error.code = "FILE_EDIT_ID_REQUIRED";123
throw error;124
}125
const editId = options.editId || legacyId;126
const edit = this.edits.get(editId);127
if (!edit || edit.target !== target || edit.identity !== identity || edit.owner !== owner) throw new Error("Reopen this file to capture an editor baseline before saving.");128
if (edit.busy || edit.uncertain) {129
const error = new Error("FILE_RESULT_UNKNOWN: Inspect recovery before starting another save.");130
error.code = "FILE_RESULT_UNKNOWN";131
throw error;132
}133
edit.busy = true;134
let recovery;135
let submitted = false;136
try {137
const hash = crypto.createHash("sha256").update(data).digest("hex");138
const id = crypto.randomBytes(16).toString("hex");139
recovery = await this.sessionManager.remoteEditor(sessionId, { operation: "prepare", id, path: target, baseline: edit.baseline, hash });140
await this._uploadEditorCopy(sessionId, recovery.temporary, data);141
submitted = true;142
const saved = await this.sessionManager.remoteEditor(sessionId, {143
operation: "finalize", id, uid: recovery.uid, path: target, baseline: edit.baseline, hash144
}, options);145
if (saved.status !== "saved" || saved.hash !== hash || saved.baseline?.hash !== hash) {146
const error = new Error("Remote save could not be confirmed."); error.code = "FILE_RESULT_UNKNOWN"; throw error;147
}148
edit.baseline = saved.baseline;149
let recoveryAvailable = false;150
try { await this.sessionManager.remoteEditor(sessionId, { operation: "cleanup", id, uid: recovery.uid }, options); }151
catch { recoveryAvailable = true; }152
this.logger?.info("Remote editor save confirmed.", { sessionId, path: target, bytes: data.length });153
return { path: target, size: data.length, status: "saved", editId, baselineHash: hash, recoveryId: id, recoveryAvailable };154
} catch (error) {155
const knownFailure = DEFINITE_EDITOR_FAILURES.has(error.code);156
edit.uncertain = submitted && !knownFailure;157
error.code = edit.uncertain ? "FILE_RESULT_UNKNOWN" : error.code || "FILE_UPLOAD_FAILED";158
error.recoveryId = recovery?.recoveryId;159
error.recoveryPath = recovery?.temporary;160
error.status = edit.uncertain ? "unknown" : "failed";161
error.message = `${error.code}: Save was not confirmed. Keep editor content and inspect recovery${recovery ? " (" + recovery.recoveryId + ")" : ""}.`;162
throw error;163
} finally {164
edit.busy = false;165
if (edit.dispose) this.closeText(editId, owner);166
}167
}169
async saveText(sessionId, remotePath, text, options = {}, owner = "core") {170
try { return { ok: true, value: await this.writeText(sessionId, remotePath, text, options, owner) }; }171
catch (error) { return { ok: false, error: { code: error.code || "FILE_SAVE_FAILED", message: error.message,172
status: error.status || "failed", recoveryId: error.recoveryId, recoveryPath: error.recoveryPath } }; }173
}175
_uploadEditorCopy(sessionId, temporary, data) {176
return new Promise((resolve, reject) => {177
let sftp;178
let settled = false;179
const finish = (error) => {180
if (settled) return;181
settled = true;182
clearTimeout(timer);183
try { sftp?.end?.(); } catch {}184
if (error) reject(error); else resolve();185
};186
const timer = setTimeout(() => finish(Object.assign(new Error("Editor upload timed out."), { code: "FILE_UPLOAD_TIMEOUT" })), 60000);187
timer.unref?.();188
Promise.resolve().then(() => this.sessionManager.openSftp(sessionId)).then((channel) => {189
if (settled) { try { channel.end?.(); } catch {} return; }190
sftp = channel;191
call(sftp, "writeFile", temporary, data, { flag: "wx", mode: 0o600 }).then(() => finish(), finish);192
}, finish);193
});194
}196
async _recovery(sessionId, operation, recoveryId, options = {}) {197
const request = { operation, id: recoveryId };198
if (options.sudo === true) {199
const identity = await this.sessionManager.remoteEditor(sessionId, { operation: "identity" });200
request.uid = identity.uid;201
}202
return this.sessionManager.remoteEditor(sessionId, request, options);203
}205
listRecovery(sessionId, options = {}) {206
return this._recovery(sessionId, "list", undefined, options);207
}209
async readRecovery(sessionId, recoveryId, options = {}) {210
const result = await this._recovery(sessionId, "inspect", recoveryId, options);211
const data = Buffer.from(result.data, "base64");212
if (!result.complete || data.length > MAX_TEXT_BYTES || crypto.createHash("sha256").update(data).digest("hex") !== result.hash) {213
throw new Error("FILE_RECOVERY_UNVERIFIED: Recovery content could not be verified.");214
}215
let text;216
try { text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(data); }217
catch { throw new Error("Recovery file is not valid UTF-8 text."); }218
const { data: _data, ...summary } = result;219
return { ...summary, text };220
}222
cleanupRecovery(sessionId, recoveryId, options = {}) {223
return this._recovery(sessionId, "cleanup", recoveryId, options);224
}226
closeText(editId, owner = "core") {227
const edit = this.edits.get(editId);228
if (!edit || edit.owner !== owner || edit.busy) return { closed: false };229
this.edits.delete(editId);230
const key = JSON.stringify([owner, edit.identity, edit.target]);231
const remaining = [...this.edits].filter(([, other]) => other.owner === owner && other.identity === edit.identity && other.target === edit.target);232
if (!remaining.length) this.legacyEdits.delete(key);233
else this.legacyEdits.set(key, remaining.length === 1 ? remaining[0][0] : null);234
return { closed: true };235
}237
cleanupPlugin(owner) {238
for (const [id, edit] of this.edits) {239
if (edit.owner !== owner) continue;240
if (edit.busy) edit.dispose = true;241
else this.closeText(id, owner);242
}243
}245
async createFile(sessionId, remotePath) {246
const target = normalizeRemotePath(remotePath);247
if (target === "/") throw new Error("The remote root cannot be a file.");248
return this._withSftp(sessionId, async (sftp) => {249
await call(sftp, "writeFile", target, Buffer.alloc(0), { flag: "wx", mode: 0o644 });250
this.logger?.warn("Remote file created.", { sessionId, path: target });251
return { path: target, size: 0 };252
});253
}255
async mkdir(sessionId, remotePath) {256
const target = normalizeRemotePath(remotePath);257
if (target === "/") throw new Error("The remote root already exists.");258
return this._withSftp(sessionId, async (sftp) => {259
await call(sftp, "mkdir", target, { mode: 0o755 });260
this.logger?.warn("Remote directory created.", { sessionId, path: target });261
return { path: target };262
});263
}265
async rename(sessionId, fromPath, toPath) {266
const from = normalizeRemotePath(fromPath); const to = normalizeRemotePath(toPath);267
if (from === "/" || to === "/") throw new Error("The remote root cannot be renamed.");268
return this._withSftp(sessionId, async (sftp) => {269
await call(sftp, "rename", from, to);270
this.logger?.warn("Remote entry renamed.", { sessionId, from, to });271
return { from, to };272
});273
}275
async remove(sessionId, remotePath) {276
const target = normalizeRemotePath(remotePath);277
if (target === "/") throw new Error("The remote root cannot be removed.");278
return this._withSftp(sessionId, async (sftp) => {279
const attrs = await call(sftp, "lstat", target);280
const isDirectory = typeof attrs?.isDirectory === "function" && attrs.isDirectory();281
await call(sftp, isDirectory ? "rmdir" : "unlink", target);282
this.logger?.warn("Remote entry removed.", { sessionId, path: target, type: isDirectory ? "directory" : "file" });283
return { path: target, type: isDirectory ? "directory" : "file" };284
});285
}287
async removeMany(sessionId, remotePaths, recursive = false) {288
const targets = this._normalizeSelection(remotePaths);289
if (targets.some((target) => target === "/")) throw new Error("The remote root cannot be removed.");290
return this._withSftp(sessionId, async (sftp) => {291
const state = { entries: 0, bytes: 0 };292
for (const target of [...targets].sort((left, right) => right.length - left.length)) await this._removeRemoteEntry(sftp, target, Boolean(recursive), state);293
this.logger?.warn("Remote entries removed.", { sessionId, selected: targets.length, entries: state.entries, recursive: Boolean(recursive) });294
return { selected: targets.length, entries: state.entries };295
});296
}298
async upload(sessionId, remoteDirectory) {299
const directory = normalizeRemotePath(remoteDirectory || "/");300
if (typeof this.selectUploadPath !== "function") throw new Error("Upload dialog is unavailable.");301
const selected = await this.selectUploadPath();302
const localPaths = selectedPaths(selected);303
if (!localPaths.length) return { canceled: true, items: [] };304
if (localPaths.length > MAX_SELECTED_ENTRIES) throw new Error(`At most ${MAX_SELECTED_ENTRIES} entries can be uploaded at once.`);305
return this._withSftp(sessionId, async (sftp) => {306
const state = { entries: 0, bytes: 0 };307
const items = [];308
for (const localPath of localPaths) {309
const target = path.posix.join(directory, path.basename(localPath));310
const result = await this._uploadLocalEntry(sftp, localPath, target, state);311
items.push({ path: target, type: result.type, size: result.size });312
}313
this.logger?.info("Entries uploaded through Core.", { sessionId, directory, entries: state.entries, bytes: state.bytes });314
return { canceled: false, items, entries: state.entries, size: state.bytes };315
});316
}318
async download(sessionId, remotePath) {319
const target = normalizeRemotePath(remotePath);320
if (typeof this.selectDownloadPath !== "function") throw new Error("Download dialog is unavailable.");321
return this._withSftp(sessionId, async (sftp) => {322
const attrs = await call(sftp, "stat", target);323
if (typeof attrs?.isDirectory === "function" && attrs.isDirectory()) throw new Error("Directory download is not available in this first File Manager version.");324
const size = Math.max(0, Number(attrs?.size) || 0);325
if (size > MAX_TRANSFER_BYTES) throw new Error("Remote file exceeds the transfer size limit.");326
const selected = await this.selectDownloadPath(path.posix.basename(target));327
if (!selected || selected.canceled || !selected.path) return { canceled: true };328
await call(sftp, "fastGet", target, selected.path);329
this.logger?.info("File downloaded through Core.", { sessionId, path: target, bytes: size });330
return { canceled: false, path: target, size };331
});332
}334
async downloadMany(sessionId, remotePaths) {335
const targets = this._normalizeSelection(remotePaths);336
if (typeof this.selectDownloadDirectory !== "function") throw new Error("Download directory dialog is unavailable.");337
const selected = await this.selectDownloadDirectory();338
if (!selected || selected.canceled || !selected.path) return { canceled: true, items: [] };339
return this._withSftp(sessionId, async (sftp) => {340
const state = { entries: 0, bytes: 0 };341
const items = [];342
for (const target of targets) {343
if (target === "/") throw new Error("Downloading the entire remote root is not allowed in one batch.");344
const localPath = path.join(selected.path, safeEntryName(path.posix.basename(target)));345
const result = await this._downloadRemoteEntry(sftp, target, localPath, state);346
items.push({ path: target, type: result.type, size: result.size });347
}348
this.logger?.info("Entries downloaded through Core.", { sessionId, entries: state.entries, bytes: state.bytes });349
return { canceled: false, items, entries: state.entries, size: state.bytes };350
});351
}353
async downloadArchive(sessionId, remotePaths, requestedFormat) {354
const targets = this._normalizeSelection(remotePaths);355
if (targets.some((target) => target === "/")) throw new Error("Archiving the entire remote root is not allowed.");356
const format = String(requestedFormat || "zip").toLowerCase() === "tgz" ? "tar.gz" : String(requestedFormat || "zip").toLowerCase();357
if (!ARCHIVE_FORMATS.has(format)) throw new Error("Supported archive formats are zip, tar and tar.gz.");358
if (typeof this.selectArchivePath !== "function") throw new Error("Archive save dialog is unavailable.");359
const baseName = targets.length === 1 ? safeEntryName(path.posix.basename(targets[0])) : "remote-selection";360
const selected = await this.selectArchivePath(`${baseName}.${format}`, format);361
if (!selected || selected.canceled || !selected.path) return { canceled: true };362
const temporaryRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), "chj-file-archive-"));363
try {364
const transfer = await this._withSftp(sessionId, async (sftp) => {365
const state = { entries: 0, bytes: 0 };366
for (const target of targets) {367
await this._downloadRemoteEntry(sftp, target, path.join(temporaryRoot, safeEntryName(path.posix.basename(target))), state);368
}369
return state;370
});371
const archiveSize = await this._createArchive(temporaryRoot, selected.path, format);372
this.logger?.info("Remote selection archived through Core.", { sessionId, format, entries: transfer.entries, sourceBytes: transfer.bytes, archiveBytes: archiveSize });373
return { canceled: false, path: selected.path, format, entries: transfer.entries, sourceSize: transfer.bytes, size: archiveSize };374
} finally {375
await fs.promises.rm(temporaryRoot, { recursive: true, force: true }).catch(() => {});376
}377
}379
_normalizeSelection(remotePaths) {380
const values = Array.isArray(remotePaths) ? remotePaths : [remotePaths];381
const targets = [...new Set(values.filter((value) => value !== undefined && value !== null).map(normalizeRemotePath))];382
if (!targets.length || targets.length > MAX_SELECTED_ENTRIES) throw new Error(`Select between 1 and ${MAX_SELECTED_ENTRIES} remote entries.`);383
return targets;384
}386
_countTransfer(state, bytes = 0) {387
state.entries += 1;388
state.bytes += Math.max(0, Number(bytes) || 0);389
if (state.entries > MAX_BATCH_ENTRIES) throw new Error(`Transfer exceeds the ${MAX_BATCH_ENTRIES} entry limit.`);390
if (state.bytes > MAX_TRANSFER_BYTES) throw new Error("Transfer exceeds the aggregate size limit.");391
}393
async _uploadLocalEntry(sftp, localPath, remotePath, state) {394
const attrs = await fs.promises.lstat(localPath);395
if (attrs.isSymbolicLink()) throw new Error("Uploading symbolic links is not supported.");396
if (attrs.isDirectory()) {397
this._countTransfer(state);398
try { await call(sftp, "mkdir", remotePath, { mode: 0o755 }); }399
catch (error) {400
const existing = await call(sftp, "stat", remotePath).catch(() => null);401
if (!existing || typeof existing.isDirectory !== "function" || !existing.isDirectory()) throw error;402
}403
for (const entry of await fs.promises.readdir(localPath, { withFileTypes: true })) {404
const name = safeEntryName(entry.name);405
await this._uploadLocalEntry(sftp, path.join(localPath, name), path.posix.join(remotePath, name), state);406
}407
return { type: "directory", size: 0 };408
}409
if (!attrs.isFile()) throw new Error("Only regular files and directories can be uploaded.");410
this._countTransfer(state, attrs.size);411
const temporary = path.posix.join(path.posix.dirname(remotePath), `.${path.posix.basename(remotePath)}.chj-upload-${crypto.randomBytes(6).toString("hex")}.tmp`);412
try {413
await call(sftp, "fastPut", localPath, temporary);414
await call(sftp, "rename", temporary, remotePath);415
} catch (error) {416
try { await call(sftp, "unlink", temporary); } catch {}417
throw error;418
}419
return { type: "file", size: attrs.size };420
}422
async _downloadRemoteEntry(sftp, remotePath, localPath, state) {423
const attrs = await call(sftp, "lstat", remotePath);424
if (typeof attrs?.isSymbolicLink === "function" && attrs.isSymbolicLink()) throw new Error(`Symbolic links are not included in recursive downloads: ${remotePath}`);425
if (typeof attrs?.isDirectory === "function" && attrs.isDirectory()) {426
this._countTransfer(state);427
await fs.promises.mkdir(localPath, { recursive: false, mode: 0o700 }).catch((error) => { if (error.code !== "EEXIST") throw error; });428
for (const entry of await call(sftp, "readdir", remotePath) || []) {429
const name = safeEntryName(entry.filename);430
await this._downloadRemoteEntry(sftp, path.posix.join(remotePath, name), path.join(localPath, name), state);431
}432
return { type: "directory", size: 0 };433
}434
const size = Math.max(0, Number(attrs?.size) || 0);435
this._countTransfer(state, size);436
await fs.promises.mkdir(path.dirname(localPath), { recursive: true, mode: 0o700 });437
const temporary = path.join(path.dirname(localPath), `.${path.basename(localPath)}.chj-download-${crypto.randomBytes(6).toString("hex")}.tmp`);438
try {439
await call(sftp, "fastGet", remotePath, temporary);440
await fs.promises.rename(temporary, localPath);441
} catch (error) {442
await fs.promises.unlink(temporary).catch(() => {});443
throw error;444
}445
return { type: "file", size };446
}448
async _removeRemoteEntry(sftp, remotePath, recursive, state) {449
const attrs = await call(sftp, "lstat", remotePath);450
this._countTransfer(state, Number(attrs?.size) || 0);451
if (typeof attrs?.isDirectory === "function" && attrs.isDirectory()) {452
if (recursive) for (const entry of await call(sftp, "readdir", remotePath) || []) await this._removeRemoteEntry(sftp, path.posix.join(remotePath, safeEntryName(entry.filename)), true, state);453
await call(sftp, "rmdir", remotePath);454
} else await call(sftp, "unlink", remotePath);455
}457
_createArchive(sourceRoot, destination, format) {458
return new Promise((resolve, reject) => {459
const output = fs.createWriteStream(destination, { flags: "w", mode: 0o600 });460
const archive = format === "zip" ? archiver("zip", { zlib: { level: 9 } }) : archiver("tar", { gzip: format === "tar.gz", gzipOptions: { level: 9 } });461
let settled = false;462
const fail = (error) => { if (settled) return; settled = true; try { archive.destroy(); } catch {} reject(error); };463
output.on("close", () => { if (!settled) { settled = true; resolve(Number(archive.pointer()) || 0); } });464
output.on("error", fail); archive.on("warning", fail); archive.on("error", fail);465
archive.pipe(output);466
archive.directory(sourceRoot, false);467
Promise.resolve(archive.finalize()).catch(fail);468
});469
}471
async _withSftp(sessionId, action) {472
const sftp = await this.sessionManager.openSftp(sessionId);473
try { return await action(sftp); }474
finally { try { sftp.end?.(); } catch {} }475
}476
}478
module.exports = { ARCHIVE_FORMATS, MAX_BATCH_ENTRIES, MAX_TEXT_BYTES, MAX_TRANSFER_BYTES, RemoteFileService, normalizeRemotePath, safeEntryName };SHA-256: e4bb3fe7160d59a22a33b8a7c346f3e74fdfdda8d21bbff3af6b30e785da8f0b
Archive SHA-256: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0