basic-ftp
Advanced tools
Comparing version 2.8.3 to 2.9.0
# Changelog | ||
## 2.9.0 | ||
- Added: Report transfer progress with client.trackProgress(). | ||
- Added: Error return codes can be ignored when removing a single file. | ||
- Fixed: Timeout behaviour of control and data socket. | ||
## 2.8.3 | ||
@@ -4,0 +10,0 @@ |
380
lib/ftp.js
@@ -7,6 +7,9 @@ "use strict"; | ||
const path = require("path"); | ||
const EventEmitter = require("events"); | ||
const promisify = require("util").promisify; | ||
const defaultParseList = require("./parseList"); | ||
const nullObject = require("./nullObject"); | ||
const FTPContext = require("./FtpContext"); | ||
const FileInfo = require("./FileInfo"); | ||
const StringWriter = require("./StringWriter"); | ||
const ProgressTracker = require("./ProgressTracker"); | ||
@@ -17,225 +20,17 @@ const fsReadDir = promisify(fs.readdir); | ||
const LF = "\n"; | ||
/** | ||
* @typedef {Object} PositiveResponse | ||
* @property {number} code The FTP return code parsed from the FTP return message. | ||
* @property {string} message The whole unparsed FTP return message. | ||
*/ | ||
/** | ||
* FTPContext holds the state of an FTP client – its control and data connections – and provides a | ||
* simplified way to interact with an FTP server, handle responses, errors and timeouts. | ||
* @typedef {Object} NegativeResponse | ||
* @property {Object|string} error The error description. | ||
* | ||
* It doesn't implement or use any FTP commands. It's only a foundation to make writing an FTP | ||
* client as easy as possible. You won't usually instantiate this, but use `Client` below. | ||
* Negative responses are usually thrown as exceptions, not returned as values. | ||
*/ | ||
class FTPContext { | ||
/** | ||
* Instantiate an FTP context. | ||
* | ||
* @param {number} [timeout=0] Timeout in milliseconds to apply to control and data connections. Use 0 for no timeout. | ||
* @param {string} [encoding="utf8"] Encoding to use for control connection. UTF-8 by default. Use "latin1" for older servers. | ||
*/ | ||
constructor(timeout = 0, encoding = "utf8") { | ||
// Timeout applied to all connections. | ||
this._timeout = timeout; | ||
// Current task to be resolved or rejected. | ||
this._task = undefined; | ||
// Function that handles incoming messages and resolves or rejects a task. | ||
this._handler = undefined; | ||
// A multiline response might be received as multiple chunks. | ||
this._partialResponse = ""; | ||
// The encoding used when reading from and writing on the control socket. | ||
this.encoding = encoding; | ||
// Options for TLS connections. | ||
this.tlsOptions = {}; | ||
// The client can log every outgoing and incoming message. | ||
this.verbose = false; | ||
// The control connection to the FTP server. | ||
this.socket = new Socket(); | ||
// The data connection to the FTP server. | ||
this.dataSocket = undefined; | ||
} | ||
/** | ||
* Close control and data connections. | ||
*/ | ||
close() { | ||
this.log("Closing sockets."); | ||
this._closeSocket(this._socket); | ||
this._closeSocket(this._dataSocket); | ||
} | ||
/** @type {Socket} */ | ||
get socket() { | ||
return this._socket; | ||
} | ||
/** | ||
* Set the socket for the control connection. This will *not* close the former control socket automatically. | ||
* | ||
* @type {Socket} | ||
*/ | ||
set socket(socket) { | ||
if (this._socket) { | ||
// Don't close the existing control socket automatically. | ||
// The setter might have been called to upgrade an existing connection. | ||
this._socket.removeAllListeners(); | ||
} | ||
this._socket = this._setupSocket(socket); | ||
if (this._socket) { | ||
this._socket.setKeepAlive(true); | ||
this._socket.on("data", data => this._onControlSocketData(data)); | ||
} | ||
} | ||
/** @type {Socket} */ | ||
get dataSocket() { | ||
return this._dataSocket; | ||
} | ||
/** | ||
* Set the socket for the data connection. This will automatically close the former data socket. | ||
* | ||
* @type {Socket} | ||
**/ | ||
set dataSocket(socket) { | ||
this._closeSocket(this._dataSocket); | ||
this._dataSocket = this._setupSocket(socket); | ||
} | ||
/** | ||
* Return true if the control socket is using TLS. This does not mean that a session | ||
* has already been negotiated. | ||
* | ||
* @returns {boolean} | ||
*/ | ||
get hasTLS() { | ||
return this._socket && this._socket.encrypted === true; | ||
} | ||
/** | ||
* Send an FTP command and handle any response until the new task is resolved. This returns a Promise that | ||
* will hold whatever the handler passed on when resolving/rejecting its task. | ||
* | ||
* @param {string} command | ||
* @param {HandlerCallback} handler | ||
* @returns {Promise<any>} | ||
*/ | ||
handle(command, handler) { | ||
return new Promise((resolvePromise, rejectPromise) => { | ||
this._handler = handler; | ||
this._task = { | ||
// When resolving or rejecting we also want the handler | ||
// to no longer receive any responses or errors. | ||
resolve: (...args) => { | ||
this._handler = undefined; | ||
resolvePromise(...args); | ||
}, | ||
reject: (...args) => { | ||
this._handler = undefined; | ||
rejectPromise(...args); | ||
} | ||
}; | ||
if (command !== undefined) { | ||
this.send(command); | ||
} | ||
}); | ||
} | ||
/** | ||
* Send an FTP command without waiting for or handling the result. | ||
* | ||
* @param {string} command | ||
*/ | ||
send(command) { | ||
// Don't log passwords. | ||
const message = command.startsWith("PASS") ? "> PASS ###" : `> ${command}`; | ||
this.log(message); | ||
this._socket.write(command + "\r\n", this._encoding); | ||
} | ||
/** | ||
* Log message if set to be verbose. | ||
* | ||
* @param {string} message | ||
*/ | ||
log(message) { | ||
if (this.verbose) { | ||
console.log(message); | ||
} | ||
} | ||
/** | ||
* Handle incoming data on the control socket. | ||
* | ||
* @private | ||
* @param {Buffer} data | ||
*/ | ||
_onControlSocketData(data) { | ||
let response = data.toString(this._encoding).trim(); | ||
this.log(`< ${response}`); | ||
// This response might complete an earlier partial response. | ||
response = this._partialResponse + response; | ||
const parsed = parseControlResponse(response); | ||
// Remember any incomplete remainder. | ||
this._partialResponse = parsed.rest; | ||
// Each response group is passed along individually. | ||
for (const message of parsed.messages) { | ||
const code = parseInt(message.substr(0, 3), 10); | ||
this._respond({ code, message }); | ||
} | ||
} | ||
/** | ||
* Send the current handler a payload. This is usually a control socket response | ||
* or a socket event, like an error or timeout. | ||
* | ||
* @private | ||
* @param {Object} payload | ||
*/ | ||
_respond(payload) { | ||
if (this._handler) { | ||
this._handler(payload, this._task); | ||
} | ||
} | ||
/** | ||
* Configure socket properties common to both control and data socket connections. | ||
* | ||
* @private | ||
* @param {Socket} socket | ||
*/ | ||
_setupSocket(socket) { | ||
if (socket) { | ||
// All sockets share the same timeout. | ||
socket.setTimeout(this._timeout); | ||
// Reroute any events to the single communication channel with the currently responsible handler. | ||
// In case of an error, the following will happen: | ||
// 1. The current handler will receive a response with the error description. | ||
// 2. The handler should then handle the error by at least rejecting the associated task. | ||
// 3. This rejection will then reject the Promise associated with the task. | ||
// 4. This rejected promise will then lead to an exception in the user's application code. | ||
socket.once("error", error => this._respond({ error })); // An error will automatically close a socket. | ||
// Report timeouts as errors. | ||
socket.once("timeout", () => { | ||
socket.destroy(); // A timeout does not automatically close a socket. | ||
this._respond({ error: "Timeout" }); | ||
}); | ||
} | ||
return socket; | ||
} | ||
/** | ||
* Close a socket. | ||
* | ||
* @private | ||
* @param {Socket} socket | ||
*/ | ||
_closeSocket(socket) { | ||
if (socket) { | ||
socket.removeAllListeners(); | ||
socket.destroy(); | ||
} | ||
} | ||
} | ||
/** | ||
* An FTP client. | ||
* FTP client using an FTPContext. | ||
*/ | ||
@@ -252,3 +47,4 @@ class Client { | ||
this.prepareTransfer = enterPassiveModeIPv4; | ||
this.parseList = defaultParseList; | ||
this.parseList = defaultParseList; | ||
this._progressTracker = new ProgressTracker(); | ||
} | ||
@@ -261,18 +57,6 @@ | ||
this.ftp.close(); | ||
this._progressTracker.stop(); | ||
} | ||
/** | ||
* @typedef {Object} PositiveResponse | ||
* @property {number} code The FTP return code parsed from the FTP return message. | ||
* @property {string} message The whole unparsed FTP return message. | ||
*/ | ||
/** | ||
* @typedef {Object} NegativeResponse | ||
* @property {Object|string} error The error description. | ||
* | ||
* Negative responses are usually thrown as exceptions, not returned as values. | ||
*/ | ||
/** | ||
* Connect to an FTP server. | ||
@@ -302,3 +86,3 @@ * | ||
* @param {string} command | ||
* @param {boolean} ignoreError | ||
* @param {boolean} ignoreErrorCodes | ||
* @return {Promise<PositiveResponse>} | ||
@@ -329,3 +113,3 @@ */ | ||
this.ftp.tlsOptions = options; // Keep the TLS options for later data connections that should use the same options. | ||
this.ftp.log("Control socket is using " + this.ftp.socket.getProtocol()); | ||
this.ftp.log("Control socket is using " + this.ftp.socket.getProtocol()); | ||
return ret; | ||
@@ -396,3 +180,3 @@ } | ||
* | ||
* @returns {Map<string, string>} | ||
* @returns {Promise<Map<string, string>>} | ||
*/ | ||
@@ -405,3 +189,3 @@ async features() { | ||
// The first and last line wrap the multiline response, ignore them. | ||
res.message.split(LF).slice(1, -1).forEach(line => { | ||
res.message.split("\n").slice(1, -1).forEach(line => { | ||
// A typical lines looks like: " REST STREAM" or " MDTM". | ||
@@ -432,10 +216,31 @@ // Servers might not use an indentation though. | ||
* | ||
* @param {string} filename | ||
* @param {string} filename | ||
* @param {boolean} ignoreErrorCodes | ||
* @returns {Promise<PositiveResponse>} | ||
*/ | ||
remove(filename) { | ||
return this.send("DELE " + filename); | ||
remove(filename, ignoreErrorCodes = false) { | ||
return this.send("DELE " + filename, ignoreErrorCodes); | ||
} | ||
/** | ||
* @typedef {Object} ProgressInfo | ||
* @property {string} name A name describing this info, e.g. the filename of the transfer. | ||
* @property {string} type The type of transfer, typically "upload" or "download". | ||
* @property {number} bytes Transferred bytes in current transfer. | ||
* @property {number} bytesOverall Transferred bytes since last counter reset. Useful for tracking multiple transfers. | ||
*/ | ||
/** | ||
* Start reporting transfer progress for any upload or download. This will also reset the | ||
* overall transfer counter that can be used for multiple transfers. Progress information | ||
* will be reported to the given handler. You may use `undefined` to stop any reporting. | ||
* | ||
* @param {((info: ProgressInfo) => void)} [handler] Handler function to call on transfer progress. | ||
*/ | ||
trackProgress(handler) { | ||
this._progressTracker.bytesOverall = 0; | ||
this._progressTracker.reportTo(handler); | ||
} | ||
/** | ||
* Upload data from a readable stream and store it as a file with | ||
@@ -450,3 +255,3 @@ * a given filename in the current working directory. | ||
await this.prepareTransfer(this.ftp); | ||
return upload(this.ftp, readableStream, remoteFilename); | ||
return upload(this.ftp, this._progressTracker, readableStream, remoteFilename); | ||
} | ||
@@ -467,3 +272,3 @@ | ||
const command = startAt > 0 ? `REST ${startAt}` : `RETR ${remoteFilename}`; | ||
return download(this.ftp, writableStream, command, remoteFilename); | ||
return download(this.ftp, this._progressTracker, writableStream, command, remoteFilename); | ||
} | ||
@@ -479,3 +284,3 @@ | ||
const writable = new StringWriter(this.ftp.encoding); | ||
await download(this.ftp, writable, "LIST"); | ||
await download(this.ftp, nullObject(), writable, "LIST"); | ||
this.ftp.log(writable.text); | ||
@@ -635,6 +440,5 @@ return this.parseList(writable.text); | ||
FileInfo, | ||
// Useful for custom extensions. | ||
// Expose some utilities for custom extensions: | ||
utils: { | ||
upgradeSocket, | ||
parseControlResponse, | ||
parseIPv4PasvResponse, | ||
@@ -650,3 +454,3 @@ TransferResolver | ||
* @param {number} code | ||
* @param {boolean} | ||
* @return {boolean} | ||
*/ | ||
@@ -657,6 +461,2 @@ function positiveCompletion(code) { | ||
function isSingle(line) { | ||
return /^\d\d\d /.test(line); | ||
} | ||
function isMultiline(line) { | ||
@@ -674,42 +474,2 @@ return /^\d\d\d-/.test(line); | ||
/** | ||
* Parse an FTP control response as a collection of messages. A message is a complete | ||
* single- or multiline response. A response can also contain multiple multiline responses | ||
* that will each be represented by a message. A response can also be incomplete | ||
* and be completed on the next incoming data chunk for which case this function also | ||
* describes a `rest`. This function converts all CRLF to LF. | ||
* | ||
* @param {string} text | ||
* @returns {{messages: string[], rest: string}} | ||
*/ | ||
function parseControlResponse(text) { | ||
const lines = text.split(/\r?\n/); | ||
const messages = []; | ||
let startAt = 0; | ||
let token = ""; | ||
for (let i = 0; i < lines.length; i++) { | ||
const line = lines[i]; | ||
// No group has been opened. | ||
if (token === "") { | ||
if (isMultiline(line)) { | ||
// Open a group by setting an expected token. | ||
token = line.substr(0, 3) + " "; | ||
startAt = i; | ||
} | ||
else if (isSingle(line)) { | ||
// Single lines can be grouped immediately. | ||
messages.push(line); | ||
} | ||
} | ||
// Group has been opened, expect closing token. | ||
else if (line.startsWith(token)) { | ||
token = ""; | ||
messages.push(lines.slice(startAt, i + 1).join(LF)); | ||
} | ||
} | ||
// The last group might not have been closed, report it as a rest. | ||
const rest = token !== "" ? lines.slice(startAt).join(LF) + LF : ""; | ||
return { messages, rest }; | ||
} | ||
/** | ||
* Upgrade a socket connection with TLS. | ||
@@ -719,3 +479,3 @@ * | ||
* @param {Object} options Same options as in `tls.connect(options)` | ||
* @returns {Promise<TLSSocket>} | ||
* @returns {Promise<tls.TLSSocket>} | ||
*/ | ||
@@ -747,3 +507,3 @@ function upgradeSocket(socket, options) { | ||
* | ||
* @param {FTP} ftp | ||
* @param {FTPContext} ftp | ||
* @returns {Promise<PositiveResponse>} | ||
@@ -817,3 +577,3 @@ */ | ||
* | ||
* @param {FTP} ftp | ||
* @param {FTPContext} ftp | ||
* @param {stream.Readable} readableStream | ||
@@ -823,3 +583,3 @@ * @param {string} remoteFilename | ||
*/ | ||
function upload(ftp, readableStream, remoteFilename) { | ||
function upload(ftp, progress, readableStream, remoteFilename) { | ||
const resolver = new TransferResolver(ftp); | ||
@@ -834,5 +594,6 @@ const command = "STOR " + remoteFilename; | ||
ftp.log(`Uploading (${describeTLS(ftp.dataSocket)})`); | ||
progress.start(ftp.dataSocket, remoteFilename, "upload"); | ||
readableStream.pipe(ftp.dataSocket).once("finish", () => { | ||
// Explicitly close/destroy the socket to signal the end. | ||
ftp.dataSocket.destroy(); | ||
ftp.dataSocket.destroy(); // Explicitly close/destroy the socket to signal the end. | ||
progress.updateAndStop(); | ||
resolver.confirm(task); | ||
@@ -846,2 +607,3 @@ }); | ||
else if (res.code >= 400 || res.error) { | ||
progress.updateAndStop(); | ||
resolver.reject(task, res); | ||
@@ -855,9 +617,9 @@ } | ||
* | ||
* @param {FTP} ftp | ||
* @param {FTPContext} ftp | ||
* @param {stream.Writable} writableStream | ||
* @param {string} command | ||
* @param {filename} [remoteFilename] | ||
* @param {string} [remoteFilename] | ||
* @returns {Promise<PositiveResponse>} | ||
*/ | ||
function download(ftp, writableStream, command, remoteFilename = "") { | ||
function download(ftp, progress, writableStream, command, remoteFilename = "") { | ||
// It's possible that data transmission begins before the control socket | ||
@@ -870,2 +632,3 @@ // receives the announcement. Start listening for data immediately. | ||
ftp.log(`Downloading (${describeTLS(ftp.dataSocket)})`); | ||
progress.start(ftp.dataSocket, remoteFilename, "download"); | ||
// Confirm the transfer as soon as the data socket transmission ended. | ||
@@ -875,3 +638,6 @@ // It's possible, though, that the data transmission is complete before | ||
// Check if the data socket is not already closed. | ||
conditionOrEvent(ftp.dataSocket.destroyed, ftp.dataSocket, "end", () => resolver.confirm(task)); | ||
conditionOrEvent(ftp.dataSocket.destroyed, ftp.dataSocket, "end", () => { | ||
progress.updateAndStop(); | ||
resolver.confirm(task); | ||
}); | ||
} | ||
@@ -885,2 +651,3 @@ else if (res.code === 350) { // Restarting at startAt. | ||
else if (res.code >= 400 || res.error) { | ||
progress.updateAndStop(); | ||
resolver.reject(task, res); | ||
@@ -909,17 +676,2 @@ } | ||
class StringWriter extends EventEmitter { | ||
constructor(encoding) { | ||
super(); | ||
this.encoding = encoding; | ||
this.text = ""; | ||
this.write = this.end = this.append; | ||
} | ||
append(chunk) { | ||
if (chunk) { | ||
this.text += chunk.toString(this.encoding); | ||
} | ||
} | ||
} | ||
/** | ||
@@ -926,0 +678,0 @@ * Upload the contents of a local directory to the working directory. This will overwrite |
@@ -14,3 +14,3 @@ "use strict"; | ||
*/ | ||
module.exports = function(rawList) { | ||
module.exports = function parseList(rawList) { | ||
const lines = rawList.split(/\r?\n/) | ||
@@ -17,0 +17,0 @@ // Strip possible multiline prefix |
{ | ||
"name": "basic-ftp", | ||
"version": "2.8.3", | ||
"version": "2.9.0", | ||
"description": "FTP client for Node.js with support for explicit FTPS over TLS.", | ||
@@ -5,0 +5,0 @@ "main": "./lib/ftp", |
@@ -5,9 +5,9 @@ # Basic FTP | ||
This is an FTP client for Node.js, it supports explicit FTPS over TLS. | ||
This is an FTP client for Node.js. It supports explicit FTPS over TLS, has a Promise-based API and offers methods to operate on whole directories. | ||
## Goals | ||
Provide a foundation that covers the usual needs and make it possible to extend functionality. | ||
Provide a foundation that covers the usual needs. | ||
FTP is an old protocol, there are many features, quirks and server implementations. It's not a goal to support all of them. Instead, make it possible to extend behaviour without requiring a change in the library itself. | ||
FTP is an old protocol, there are many features, quirks and server implementations. It's not a goal to support all of them. Instead, the library should focus on being easy to read, tinker with and extend. | ||
@@ -20,3 +20,3 @@ ## Dependencies | ||
`Client` provides an API to interact with an FTP server. The following example shows how to connect, upgrade to TLS, login, get a directory listing and upload a file. | ||
`Client` provides an API to interact with an FTP server. The following example shows how to connect, upgrade to TLS, login, get a directory listing and upload a file. Be aware that the FTP protocol doesn't allow multiple requests in parallel. | ||
@@ -53,11 +53,2 @@ ```js | ||
You can always use Promises instead of async/await. Be aware that the FTP protocol doesn't allow multiple parallel requests. | ||
```js | ||
client.ensureDir("my/remote/path") | ||
.then(() => client.clearWorkingDir()) | ||
.then(() => client.uploadDir("my/local/path")) | ||
.catch(err => console.log("Oh no!", err)) | ||
``` | ||
If you encounter a problem, it can be helpful to let the client log out all communication with the FTP server. | ||
@@ -151,2 +142,35 @@ | ||
`trackProgress(handler)` | ||
Report any transfer progress using the given handler function. See the next section for more details. | ||
## Transfer Progress | ||
You can set a callback function with `client.trackProgress` to track the progress of all uploads and downloads. To disable progress reporting, call `trackProgress` with an undefined handler. | ||
``` | ||
// Log progress for any transfer from now on. | ||
client.trackProgress(info => { | ||
console.log("File", info.name); | ||
console.log("Type", info.type); | ||
console.log("Transferred", info.bytes); | ||
console.log("Transferred Overall", info.bytesOverall); | ||
}); | ||
// Transfer some data | ||
await client.upload(someStream, "test.txt"); | ||
await client.upload(someOtherStream, "test2.txt"); | ||
// Reset overall counter | ||
client.trackProgress(info => console.log(info.bytesOverall)); | ||
await client.downloadDir("local/path"); | ||
// Stop logging | ||
client.trackProgress(); | ||
``` | ||
For each transfer, the callback function will receive a name, the transfer type (upload/download) and the number of bytes transferred so far. The function will be called at a regular interval during a transfer. | ||
In addition to that, there is also a counter for all bytes transferred since the last time `trackProgress` was called. This is useful when downloading a directory with multiple files where you want to show the total bytes downloaded so far. | ||
## Error Handling | ||
@@ -153,0 +177,0 @@ |
@@ -25,2 +25,3 @@ const assert = require("assert"); | ||
let client; | ||
beforeEach(function() { | ||
@@ -33,2 +34,6 @@ client = new Client(); | ||
afterEach(function() { | ||
client.close(); | ||
}); | ||
/** | ||
@@ -141,2 +146,8 @@ * Testing simple convenience functions follows the same pattern: | ||
}); | ||
it("resets overall bytes of progress tracker on trackProgress()", function() { | ||
client._progressTracker.bytesOverall = 5; | ||
client.trackProgress(); | ||
assert.equal(client._progressTracker.bytesOverall, 0, "bytesOverall after reset"); | ||
}); | ||
}); |
@@ -31,2 +31,6 @@ const assert = require("assert"); | ||
afterEach(function() { | ||
client.close(); | ||
}); | ||
function requestListAndVerify(done) { | ||
@@ -33,0 +37,0 @@ client.list().then(result => { |
@@ -18,6 +18,12 @@ const assert = require("assert"); | ||
const old = ftp.socket; | ||
ftp.socket = undefined; | ||
ftp.socket = new SocketMock(); | ||
assert.equal(old.destroyed, false, "Socket not destroyed."); | ||
}); | ||
it("Setting control socket to undefined destroys current", function() { | ||
const old = ftp.socket; | ||
ftp.socket = undefined; | ||
assert.equal(old.destroyed, true, "Socket destroyed."); | ||
}); | ||
it("Setting new data socket destroys current", function() { | ||
@@ -31,3 +37,3 @@ const old = ftp.dataSocket; | ||
ftp.handle(undefined, (res, task) => { | ||
assert.deepEqual(res, { error: "Timeout" }); | ||
assert.deepEqual(res, { error: "Timeout control socket" }); | ||
done(); | ||
@@ -48,3 +54,3 @@ }); | ||
ftp.handle(undefined, (res, task) => { | ||
assert.deepEqual(res, { error: "Timeout" }); | ||
assert.deepEqual(res, { error: "Timeout data socket" }); | ||
done(); | ||
@@ -51,0 +57,0 @@ }); |
const assert = require("assert"); | ||
const parseControlResponse = require("../lib/ftp").utils.parseControlResponse; | ||
const parseControlResponse = require("../lib/parseControlResponse"); | ||
@@ -4,0 +4,0 @@ const CRLF = "\r\n"; |
@@ -7,2 +7,4 @@ const EventEmitter = require("events"); | ||
this.destroyed = false; | ||
this.bytesWritten = 0; | ||
this.bytesRead = 0; | ||
} | ||
@@ -9,0 +11,0 @@ removeAllListeners() { |
@@ -19,2 +19,6 @@ const assert = require("assert"); | ||
afterEach(function() { | ||
client.close(); | ||
}); | ||
it("sends the correct command", function(done) { | ||
@@ -21,0 +25,0 @@ client.ftp.socket.once("didSend", buf => { |
123427
30
2124
277
5