molex-ftp-client 1.0.1 → 1.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ ## [1.2.0] - 2026-02-02
4
+
5
+ ### Changed
6
+ - **Major refactoring**: Improved separation of concerns
7
+ - `index.js` now serves as simple entry point
8
+ - Implementation moved to organized `lib/` structure:
9
+ - `lib/FTPClient.js` - Main class definition
10
+ - `lib/connection.js` - Connection and authentication logic
11
+ - `lib/commands.js` - All FTP command implementations
12
+ - `lib/utils.js` - Helper functions
13
+ - Better code maintainability and readability
14
+ - No breaking changes - API remains identical
15
+
16
+ ## [1.1.0] - 2026-02-02
17
+
18
+ ### Added
19
+ - `ensureDir(dirPath, recursive)` - Ensure directory exists, creating parent directories if needed
20
+ - `ensureParentDir(filePath)` - Ensure parent directory exists for a file path
21
+ - `uploadFile(data, remotePath, ensureDir)` - Upload with automatic directory creation
22
+ - Utility library (`lib/utils.js`) for better code organization
23
+ - Helper functions for FTP command parsing and path manipulation
24
+
25
+ ### Changed
26
+ - Refactored internal code structure for better maintainability
27
+ - Improved path normalization across all directory operations
28
+ - Better error handling for directory creation
29
+
30
+ ### Improved
31
+ - Cleaner API for common operations
32
+ - Reduced boilerplate code needed for directory handling
33
+ - More consistent error messages
34
+
35
+ ## [1.0.1] - 2026-02-02
36
+
37
+ ### Fixed
38
+ - Updated repository URLs to correct GitHub location
39
+
40
+ ## [1.0.0] - 2026-02-02
41
+
42
+ ### Initial Release
43
+ - Zero dependencies FTP client using native Node.js TCP sockets
44
+ - Promise-based API with async/await support
45
+ - Passive mode (PASV) for data transfers
46
+ - Debug logging with configurable options
47
+ - Connection keep-alive and timeout configuration
48
+ - Upload/download files with Buffer support
49
+ - Directory operations (list, cd, mkdir, pwd)
50
+ - File operations (delete, rename, size, exists, modifiedTime)
51
+ - Connection statistics tracking
52
+ - Event-based architecture
package/README.md CHANGED
@@ -177,6 +177,24 @@ console.log(`Last modified: ${date.toISOString()}`);
177
177
 
178
178
  Returns: `Promise<Date>`
179
179
 
180
+ #### `uploadFile(data, remotePath, ensureDir)`
181
+
182
+ Upload file and optionally ensure parent directory exists.
183
+
184
+ ```javascript
185
+ // Upload with automatic directory creation
186
+ await client.uploadFile('data', '/deep/nested/path/file.txt', true);
187
+
188
+ // Upload without directory creation (default behavior)
189
+ await client.uploadFile('data', '/file.txt');
190
+ ```
191
+
192
+ - `data` (string|Buffer): File content
193
+ - `remotePath` (string): Remote file path
194
+ - `ensureDir` (boolean): Create parent directories if needed (default: false)
195
+
196
+ Returns: `Promise<void>`
197
+
180
198
  ### Directory Operations
181
199
 
182
200
  #### `list(path)`
@@ -221,6 +239,35 @@ await client.mkdir('/remote/newdir');
221
239
 
222
240
  Returns: `Promise<void>`
223
241
 
242
+ #### `ensureDir(dirPath, recursive)`
243
+
244
+ Ensure directory exists, creating it (and parent directories) if necessary.
245
+
246
+ ```javascript
247
+ // Create nested directories recursively
248
+ await client.ensureDir('/deep/nested/path');
249
+
250
+ // Create single directory (no parent creation)
251
+ await client.ensureDir('/newdir', false);
252
+ ```
253
+
254
+ - `dirPath` (string): Directory path to ensure exists
255
+ - `recursive` (boolean): Create parent directories if needed (default: true)
256
+
257
+ Returns: `Promise<void>`
258
+
259
+ #### `ensureParentDir(filePath)`
260
+
261
+ Ensure the parent directory exists for a given file path.
262
+
263
+ ```javascript
264
+ // Ensures /path/to exists before uploading
265
+ await client.ensureParentDir('/path/to/file.txt');
266
+ await client.upload('data', '/path/to/file.txt');
267
+ ```
268
+
269
+ Returns: `Promise<void>`
270
+
224
271
  ### Utilities
225
272
 
226
273
  #### `getStats()`
package/index.js CHANGED
@@ -1,475 +1,17 @@
1
- const net = require('net');
2
- const { EventEmitter } = require('events');
3
-
4
- /**
5
- * Lightweight FTP Client using native Node.js TCP sockets (net module)
1
+ /*
2
+ * File: c:\Users\tonyw\Desktop\PRIVS\molex-ftp-client\index.js
3
+ * Project: c:\Users\tonyw\Desktop\PRIVS\molex-ftp-client
4
+ * Created Date: Monday February 2nd 2026
5
+ * Author: Tony Wiedman
6
+ * -----
7
+ * Last Modified: Mon February 2nd 2026 1:23:44
8
+ * Modified By: Tony Wiedman
9
+ * -----
10
+ * Copyright (c) 2026 MolexWorks
6
11
  */
7
- class FTPClient extends EventEmitter {
8
- constructor(options = {}) {
9
- super();
10
- this.socket = null;
11
- this.dataSocket = null;
12
- this.buffer = '';
13
- this.connected = false;
14
- this.authenticated = false;
15
- this.debug = options.debug || false;
16
- this.timeout = options.timeout || 30000;
17
- this.keepAlive = options.keepAlive !== false;
18
- this._log = options.logger || console.log;
19
- this._commandCount = 0;
20
- this._lastCommand = null;
21
- }
22
-
23
- /**
24
- * Log message if debug is enabled
25
- * @private
26
- */
27
- _debug(...args) {
28
- if (this.debug && this._log) {
29
- this._log('[FTP Debug]', ...args);
30
- }
31
- }
32
-
33
- /**
34
- * Connect to FTP server
35
- * @param {Object} options - Connection options
36
- * @param {string} options.host - FTP server host
37
- * @param {number} [options.port=21] - FTP server port
38
- * @param {string} [options.user='anonymous'] - Username
39
- * @param {string} [options.password='anonymous@'] - Password
40
- * @returns {Promise<void>}
41
- */
42
- async connect({ host, port = 21, user = 'anonymous', password = 'anonymous@' }) {
43
- this._debug(`Connecting to ${host}:${port} as ${user}`);
44
- return new Promise((resolve, reject) => {
45
- this.socket = net.createConnection({ host, port }, () => {
46
- this.connected = true;
47
- this._debug('TCP connection established');
48
- if (this.keepAlive) {
49
- this.socket.setKeepAlive(true, 10000);
50
- }
51
- this.emit('connected');
52
- });
53
-
54
- this.socket.setEncoding('utf8');
55
- this.socket.on('data', async (data) => {
56
- this.buffer += data;
57
- const lines = this.buffer.split('\r\n');
58
- this.buffer = lines.pop();
59
-
60
- for (const line of lines) {
61
- if (line) {
62
- this._debug('<<<', line);
63
- this.emit('response', line);
64
- const code = parseInt(line.substring(0, 3));
65
-
66
- // Handle initial connection
67
- if (code === 220 && !this.authenticated) {
68
- try {
69
- this._debug('Authenticating...');
70
- await this._sendCommand(`USER ${user}`);
71
- await this._sendCommand(`PASS ${password}`);
72
- this.authenticated = true;
73
- this._debug('Authentication successful');
74
- resolve();
75
- } catch (err) {
76
- reject(err);
77
- }
78
- }
79
- }
80
- }
81
- });
82
-
83
- this.socket.on('error', (err) => {
84
- this.emit('error', err);
85
- reject(err);
86
- });
87
-
88
- this.socket.on('close', () => {
89
- this.connected = false;
90
- this.authenticated = false;
91
- this.emit('close');
92
- });
93
-
94
- setTimeout(() => reject(new Error('Connection timeout')), 10000);
95
- });
96
- }
97
-
98
- /**
99
- * Send FTP command and wait for response
100
- * @param {string} command - FTP command
101
- * @param {boolean} allowPreliminary - Allow 1xx preliminary responses
102
- * @returns {Promise<Object>}
103
- */
104
- _sendCommand(command, allowPreliminary = false) {
105
- return new Promise((resolve, reject) => {
106
- if (!this.connected) {
107
- return reject(new Error('Not connected'));
108
- }
109
-
110
- this._commandCount++;
111
- this._lastCommand = command;
112
- const cmdToLog = command.startsWith('PASS ') ? 'PASS ********' : command;
113
- this._debug('>>>', cmdToLog);
114
-
115
- const timeoutId = setTimeout(() => {
116
- this.removeListener('response', responseHandler);
117
- reject(new Error(`Command timeout: ${cmdToLog}`));
118
- }, this.timeout);
119
-
120
- const responseHandler = (line) => {
121
- clearTimeout(timeoutId);
122
- const code = parseInt(line.substring(0, 3));
123
- const message = line.substring(4);
124
-
125
- // Check if this is a complete response (not a multi-line response in progress)
126
- if (line.charAt(3) === ' ') {
127
- // 1xx = Preliminary positive reply (command okay, another command expected)
128
- // 2xx = Positive completion reply
129
- // 3xx = Positive intermediate reply (command okay, awaiting more info)
130
- // 4xx/5xx = Negative replies (errors)
131
-
132
- if (code >= 100 && code < 200 && allowPreliminary) {
133
- // Don't remove listener, wait for final response
134
- this._debug('Preliminary response, waiting for completion...');
135
- return;
136
- }
137
-
138
- clearTimeout(timeoutId);
139
- this.removeListener('response', responseHandler);
140
-
141
- if (code >= 200 && code < 400) {
142
- resolve({ code, message, raw: line });
143
- } else {
144
- this._debug(`Error response: ${code}`);
145
- reject(new Error(`FTP Error ${code}: ${message}`));
146
- }
147
- }
148
- };
149
-
150
- this.on('response', responseHandler);
151
- this.socket.write(command + '\r\n');
152
- });
153
- }
154
-
155
- /**
156
- * Enter passive mode and get data connection info
157
- * @returns {Promise<Object>}
158
- */
159
- async _enterPassiveMode() {
160
- const response = await this._sendCommand('PASV');
161
- const match = response.message.match(/\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)/);
162
-
163
- if (!match) {
164
- throw new Error('Failed to parse PASV response');
165
- }
166
-
167
- const host = `${match[1]}.${match[2]}.${match[3]}.${match[4]}`;
168
- const port = parseInt(match[5]) * 256 + parseInt(match[6]);
169
-
170
- return { host, port };
171
- }
172
-
173
- /**
174
- * Upload file to FTP server
175
- * @param {string|Buffer} data - File data
176
- * @param {string} remotePath - Remote file path
177
- * @returns {Promise<void>}
178
- */
179
- async upload(data, remotePath) {
180
- const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
181
- this._debug(`Uploading ${buffer.length} bytes to ${remotePath}`);
182
- const { host, port } = await this._enterPassiveMode();
183
-
184
- return new Promise((resolve, reject) => {
185
- let commandSent = false;
186
-
187
- this.dataSocket = net.createConnection({ host, port }, () => {
188
- // Send STOR command to start upload (expects 150, then 226)
189
- if (!commandSent) {
190
- commandSent = true;
191
- this._debug(`Data connection established for upload`);
192
- this._sendCommand(`STOR ${remotePath}`, true).catch(reject);
193
-
194
- // Write data to data socket
195
- this.dataSocket.write(buffer);
196
- this.dataSocket.end();
197
- }
198
- });
199
-
200
- this.dataSocket.on('error', reject);
201
-
202
- this.dataSocket.on('close', () => {
203
- // Wait for final response from control socket
204
- const finalHandler = (line) => {
205
- const code = parseInt(line.substring(0, 3));
206
- if (code === 226 || code === 250) {
207
- this.removeListener('response', finalHandler);
208
- this._debug(`Upload completed successfully`);
209
- resolve();
210
- } else if (code >= 400) {
211
- this.removeListener('response', finalHandler);
212
- reject(new Error(`FTP Error ${code}: ${line.substring(4)}`));
213
- }
214
- };
215
- this.on('response', finalHandler);
216
-
217
- // Timeout if no response
218
- setTimeout(() => {
219
- this.removeListener('response', finalHandler);
220
- resolve();
221
- }, 5000);
222
- });
223
- });
224
- }
225
-
226
- /**
227
- * Download file from FTP server
228
- * @param {string} remotePath - Remote file path
229
- * @returns {Promise<Buffer>}
230
- */
231
- async download(remotePath) {
232
- this._debug(`Downloading ${remotePath}`);
233
- const { host, port } = await this._enterPassiveMode();
234
-
235
- return new Promise((resolve, reject) => {
236
- const chunks = [];
237
- let commandSent = false;
238
-
239
- this.dataSocket = net.createConnection({ host, port }, () => {
240
- // Send RETR command to start download (expects 150, then 226)
241
- if (!commandSent) {
242
- commandSent = true;
243
- this._debug(`Data connection established for download`);
244
- this._sendCommand(`RETR ${remotePath}`, true).catch(reject);
245
- }
246
- });
247
-
248
- this.dataSocket.on('data', (chunk) => {
249
- chunks.push(chunk);
250
- this._debug(`Received ${chunk.length} bytes`);
251
- });
252
-
253
- this.dataSocket.on('error', reject);
254
-
255
- this.dataSocket.on('close', () => {
256
- // Wait for final 226 response
257
- const finalHandler = (line) => {
258
- const code = parseInt(line.substring(0, 3));
259
- if (code === 226 || code === 250) {
260
- this.removeListener('response', finalHandler);
261
- const result = Buffer.concat(chunks);
262
- this._debug(`Download completed: ${result.length} bytes`);
263
- resolve(result);
264
- } else if (code >= 400) {
265
- this.removeListener('response', finalHandler);
266
- reject(new Error(`FTP Error ${code}: ${line.substring(4)}`));
267
- }
268
- };
269
- this.on('response', finalHandler);
270
-
271
- // Timeout if no response
272
- setTimeout(() => {
273
- this.removeListener('response', finalHandler);
274
- if (chunks.length > 0) {
275
- resolve(Buffer.concat(chunks));
276
- }
277
- }, 5000);
278
- });
279
- });
280
- }
281
-
282
- /**
283
- * List directory contents
284
- * @param {string} [path='.'] - Directory path
285
- * @returns {Promise<string>}
286
- */
287
- async list(path = '.') {
288
- this._debug(`Listing directory: ${path}`);
289
- const { host, port } = await this._enterPassiveMode();
290
-
291
- return new Promise((resolve, reject) => {
292
- const chunks = [];
293
- let commandSent = false;
294
-
295
- this.dataSocket = net.createConnection({ host, port }, () => {
296
- if (!commandSent) {
297
- commandSent = true;
298
- this._sendCommand(`LIST ${path}`, true).catch(reject);
299
- }
300
- });
301
-
302
- this.dataSocket.on('data', (chunk) => {
303
- chunks.push(chunk);
304
- });
305
-
306
- this.dataSocket.on('error', reject);
307
-
308
- this.dataSocket.on('close', () => {
309
- // Wait for final 226 response
310
- const finalHandler = (line) => {
311
- const code = parseInt(line.substring(0, 3));
312
- if (code === 226 || code === 250) {
313
- this.removeListener('response', finalHandler);
314
- resolve(Buffer.concat(chunks).toString('utf8'));
315
- }
316
- };
317
- this.on('response', finalHandler);
318
-
319
- // Timeout fallback
320
- setTimeout(() => {
321
- this.removeListener('response', finalHandler);
322
- resolve(Buffer.concat(chunks).toString('utf8'));
323
- }, 3000);
324
- });
325
- });
326
- }
327
-
328
- /**
329
- * Change working directory
330
- * @param {string} path - Directory path
331
- * @returns {Promise<void>}
332
- */
333
- async cd(path) {
334
- await this._sendCommand(`CWD ${path}`);
335
- }
336
-
337
- /**
338
- * Get current working directory
339
- * @returns {Promise<string>}
340
- */
341
- async pwd() {
342
- const response = await this._sendCommand('PWD');
343
- const match = response.message.match(/"(.+)"/);
344
- return match ? match[1] : '/';
345
- }
346
-
347
- /**
348
- * Create directory
349
- * @param {string} path - Directory path
350
- * @returns {Promise<void>}
351
- */
352
- async mkdir(path) {
353
- await this._sendCommand(`MKD ${path}`);
354
- }
355
-
356
- /**
357
- * Delete file
358
- * @param {string} path - File path
359
- * @returns {Promise<void>}
360
- */
361
- async delete(path) {
362
- await this._sendCommand(`DELE ${path}`);
363
- }
364
-
365
- /**
366
- * Rename file
367
- * @param {string} from - Current name
368
- * @param {string} to - New name
369
- * @returns {Promise<void>}
370
- */
371
- async rename(from, to) {
372
- await this._sendCommand(`RNFR ${from}`);
373
- await this._sendCommand(`RNTO ${to}`);
374
- }
375
-
376
- /**
377
- * Get file size
378
- * @param {string} path - File path
379
- * @returns {Promise<number>}
380
- */
381
- async size(path) {
382
- this._debug(`Getting size of ${path}`)
383
- const response = await this._sendCommand(`SIZE ${path}`);
384
- return parseInt(response.message);
385
- }
386
-
387
- /**
388
- * Check if file or directory exists
389
- * @param {string} path - File or directory path
390
- * @returns {Promise<boolean>}
391
- */
392
- async exists(path) {
393
- try {
394
- await this.size(path);
395
- return true;
396
- } catch (err) {
397
- return false;
398
- }
399
- }
400
-
401
- /**
402
- * Get file modification time
403
- * @param {string} path - File path
404
- * @returns {Promise<Date>}
405
- */
406
- async modifiedTime(path) {
407
- this._debug(`Getting modification time of ${path}`);
408
- const response = await this._sendCommand(`MDTM ${path}`);
409
- // Parse MDTM response: YYYYMMDDhhmmss
410
- const match = response.message.match(/(\d{14})/);
411
- if (match) {
412
- const str = match[1];
413
- const year = parseInt(str.substring(0, 4));
414
- const month = parseInt(str.substring(4, 6)) - 1;
415
- const day = parseInt(str.substring(6, 8));
416
- const hour = parseInt(str.substring(8, 10));
417
- const minute = parseInt(str.substring(10, 12));
418
- const second = parseInt(str.substring(12, 14));
419
- return new Date(Date.UTC(year, month, day, hour, minute, second));
420
- }
421
- throw new Error('Failed to parse MDTM response');
422
- }
423
-
424
- /**
425
- * Get connection statistics
426
- * @returns {Object}
427
- */
428
- getStats() {
429
- return {
430
- connected: this.connected,
431
- authenticated: this.authenticated,
432
- commandCount: this._commandCount,
433
- lastCommand: this._lastCommand
434
- };
435
- }
436
-
437
- /**
438
- * Enable or disable debug mode
439
- * @param {boolean} enabled - Enable debug mode
440
- */
441
- setDebug(enabled) {
442
- this.debug = enabled;
443
- this._debug(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
444
- }
445
12
 
446
- /**
447
- * Close connection
448
- * @returns {Promise<void>}
449
- */
450
- async close() {
451
- if (this.connected) {
452
- this._debug('Closing connection...');
453
- try {
454
- await this._sendCommand('QUIT');
455
- } catch (err) {
456
- this._debug('Error during QUIT:', err.message);
457
- }
458
- this.socket.end();
459
- this.connected = false;
460
- this.authenticated = false;
461
- this._debug('Connection closed');
462
- }
463
- }
464
13
 
465
- /**
466
- * Disconnect (alias for close)
467
- * @returns {Promise<void>}
468
- */
469
- async disconnect() {
470
- return this.close();
471
- }
472
- }
14
+ const FTPClient = require('./lib/FTPClient');
473
15
 
474
16
  module.exports = FTPClient;
475
17
  module.exports.FTPClient = FTPClient;
@@ -0,0 +1,226 @@
1
+ const { EventEmitter } = require('events');
2
+ const FTPConnection = require('./connection');
3
+ const FTPCommands = require('./commands');
4
+
5
+ /**
6
+ * Lightweight FTP Client using native Node.js TCP sockets (net module)
7
+ */
8
+ class FTPClient extends EventEmitter {
9
+ constructor(options = {}) {
10
+ super();
11
+
12
+ // Connection state
13
+ this.socket = null;
14
+ this.dataSocket = null;
15
+ this.buffer = '';
16
+ this.connected = false;
17
+ this.authenticated = false;
18
+
19
+ // Configuration
20
+ this.debug = options.debug || false;
21
+ this.timeout = options.timeout || 30000;
22
+ this.keepAlive = options.keepAlive !== false;
23
+ this._log = options.logger || console.log;
24
+
25
+ // Statistics
26
+ this._commandCount = 0;
27
+ this._lastCommand = null;
28
+
29
+ // Initialize subsystems
30
+ this._connection = new FTPConnection(this);
31
+ this._commands = new FTPCommands(this);
32
+ }
33
+
34
+ /**
35
+ * Log message if debug is enabled
36
+ * @private
37
+ */
38
+ _debug(...args) {
39
+ if (this.debug && this._log) {
40
+ this._log('[FTP Debug]', ...args);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Connect to FTP server
46
+ * @param {Object} options - Connection options
47
+ * @param {string} options.host - FTP server host
48
+ * @param {number} [options.port=21] - FTP server port
49
+ * @param {string} [options.user='anonymous'] - Username
50
+ * @param {string} [options.password='anonymous@'] - Password
51
+ * @returns {Promise<void>}
52
+ */
53
+ async connect(options) {
54
+ return this._connection.connect(options);
55
+ }
56
+
57
+ /**
58
+ * Upload file to FTP server
59
+ * @param {string|Buffer} data - File data
60
+ * @param {string} remotePath - Remote file path
61
+ * @returns {Promise<void>}
62
+ */
63
+ async upload(data, remotePath) {
64
+ return this._commands.upload(data, remotePath);
65
+ }
66
+
67
+ /**
68
+ * Download file from FTP server
69
+ * @param {string} remotePath - Remote file path
70
+ * @returns {Promise<Buffer>}
71
+ */
72
+ async download(remotePath) {
73
+ return this._commands.download(remotePath);
74
+ }
75
+
76
+ /**
77
+ * List directory contents
78
+ * @param {string} [path='.'] - Directory path
79
+ * @returns {Promise<string>}
80
+ */
81
+ async list(path = '.') {
82
+ return this._commands.list(path);
83
+ }
84
+
85
+ /**
86
+ * Change working directory
87
+ * @param {string} path - Directory path
88
+ * @returns {Promise<void>}
89
+ */
90
+ async cd(path) {
91
+ return this._commands.cd(path);
92
+ }
93
+
94
+ /**
95
+ * Get current working directory
96
+ * @returns {Promise<string>}
97
+ */
98
+ async pwd() {
99
+ return this._commands.pwd();
100
+ }
101
+
102
+ /**
103
+ * Create directory
104
+ * @param {string} path - Directory path
105
+ * @returns {Promise<void>}
106
+ */
107
+ async mkdir(path) {
108
+ return this._commands.mkdir(path);
109
+ }
110
+
111
+ /**
112
+ * Delete file
113
+ * @param {string} path - File path
114
+ * @returns {Promise<void>}
115
+ */
116
+ async delete(path) {
117
+ return this._commands.delete(path);
118
+ }
119
+
120
+ /**
121
+ * Rename file
122
+ * @param {string} from - Current name
123
+ * @param {string} to - New name
124
+ * @returns {Promise<void>}
125
+ */
126
+ async rename(from, to) {
127
+ return this._commands.rename(from, to);
128
+ }
129
+
130
+ /**
131
+ * Get file size
132
+ * @param {string} path - File path
133
+ * @returns {Promise<number>}
134
+ */
135
+ async size(path) {
136
+ return this._commands.size(path);
137
+ }
138
+
139
+ /**
140
+ * Check if file or directory exists
141
+ * @param {string} path - File or directory path
142
+ * @returns {Promise<boolean>}
143
+ */
144
+ async exists(path) {
145
+ return this._commands.exists(path);
146
+ }
147
+
148
+ /**
149
+ * Ensure directory exists, creating it if necessary
150
+ * @param {string} dirPath - Directory path to ensure exists
151
+ * @param {boolean} recursive - Create parent directories if needed (default: true)
152
+ * @returns {Promise<void>}
153
+ */
154
+ async ensureDir(dirPath, recursive = true) {
155
+ return this._commands.ensureDir(dirPath, recursive);
156
+ }
157
+
158
+ /**
159
+ * Ensure parent directory exists for a file path
160
+ * @param {string} filePath - File path
161
+ * @returns {Promise<void>}
162
+ */
163
+ async ensureParentDir(filePath) {
164
+ return this._commands.ensureParentDir(filePath);
165
+ }
166
+
167
+ /**
168
+ * Upload file and ensure parent directory exists
169
+ * @param {string|Buffer} data - File data
170
+ * @param {string} remotePath - Remote file path
171
+ * @param {boolean} ensureDir - Ensure parent directory exists (default: false)
172
+ * @returns {Promise<void>}
173
+ */
174
+ async uploadFile(data, remotePath, ensureDir = false) {
175
+ return this._commands.uploadFile(data, remotePath, ensureDir);
176
+ }
177
+
178
+ /**
179
+ * Get file modification time
180
+ * @param {string} path - File path
181
+ * @returns {Promise<Date>}
182
+ */
183
+ async modifiedTime(path) {
184
+ return this._commands.modifiedTime(path);
185
+ }
186
+
187
+ /**
188
+ * Get connection statistics
189
+ * @returns {Object}
190
+ */
191
+ getStats() {
192
+ return {
193
+ connected: this.connected,
194
+ authenticated: this.authenticated,
195
+ commandCount: this._commandCount,
196
+ lastCommand: this._lastCommand
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Enable or disable debug mode
202
+ * @param {boolean} enabled - Enable debug mode
203
+ */
204
+ setDebug(enabled) {
205
+ this.debug = enabled;
206
+ this._debug(`Debug mode ${enabled ? 'enabled' : 'disabled'}`);
207
+ }
208
+
209
+ /**
210
+ * Close connection
211
+ * @returns {Promise<void>}
212
+ */
213
+ async close() {
214
+ return this._connection.close();
215
+ }
216
+
217
+ /**
218
+ * Disconnect (alias for close)
219
+ * @returns {Promise<void>}
220
+ */
221
+ async disconnect() {
222
+ return this.close();
223
+ }
224
+ }
225
+
226
+ module.exports = FTPClient;
@@ -0,0 +1,323 @@
1
+ const net = require('net');
2
+ const { normalizePath, getParentDir, parseMdtmResponse } = require('./utils');
3
+
4
+ /**
5
+ * FTP command implementations
6
+ */
7
+ class FTPCommands {
8
+ constructor(client) {
9
+ this.client = client;
10
+ this.connection = client._connection;
11
+ }
12
+
13
+ /**
14
+ * Upload file to FTP server
15
+ * @param {string|Buffer} data - File data
16
+ * @param {string} remotePath - Remote file path
17
+ * @returns {Promise<void>}
18
+ */
19
+ async upload(data, remotePath) {
20
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
21
+ this.client._debug(`Uploading ${buffer.length} bytes to ${remotePath}`);
22
+ const { host, port } = await this.connection.enterPassiveMode();
23
+
24
+ return new Promise((resolve, reject) => {
25
+ let commandSent = false;
26
+
27
+ this.client.dataSocket = net.createConnection({ host, port }, () => {
28
+ // Send STOR command to start upload (expects 150, then 226)
29
+ if (!commandSent) {
30
+ commandSent = true;
31
+ this.client._debug(`Data connection established for upload`);
32
+ this.connection.sendCommand(`STOR ${remotePath}`, true).catch(reject);
33
+
34
+ // Write data to data socket
35
+ this.client.dataSocket.write(buffer);
36
+ this.client.dataSocket.end();
37
+ }
38
+ });
39
+
40
+ this.client.dataSocket.on('error', reject);
41
+
42
+ this.client.dataSocket.on('close', () => {
43
+ // Wait for final response from control socket
44
+ const finalHandler = (line) => {
45
+ const code = parseInt(line.substring(0, 3));
46
+ if (code === 226 || code === 250) {
47
+ this.client.removeListener('response', finalHandler);
48
+ this.client._debug(`Upload completed successfully`);
49
+ resolve();
50
+ } else if (code >= 400) {
51
+ this.client.removeListener('response', finalHandler);
52
+ reject(new Error(`FTP Error ${code}: ${line.substring(4)}`));
53
+ }
54
+ };
55
+ this.client.on('response', finalHandler);
56
+
57
+ // Timeout if no response
58
+ setTimeout(() => {
59
+ this.client.removeListener('response', finalHandler);
60
+ resolve();
61
+ }, 5000);
62
+ });
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Download file from FTP server
68
+ * @param {string} remotePath - Remote file path
69
+ * @returns {Promise<Buffer>}
70
+ */
71
+ async download(remotePath) {
72
+ this.client._debug(`Downloading ${remotePath}`);
73
+ const { host, port } = await this.connection.enterPassiveMode();
74
+
75
+ return new Promise((resolve, reject) => {
76
+ const chunks = [];
77
+ let commandSent = false;
78
+
79
+ this.client.dataSocket = net.createConnection({ host, port }, () => {
80
+ // Send RETR command to start download (expects 150, then 226)
81
+ if (!commandSent) {
82
+ commandSent = true;
83
+ this.client._debug(`Data connection established for download`);
84
+ this.connection.sendCommand(`RETR ${remotePath}`, true).catch(reject);
85
+ }
86
+ });
87
+
88
+ this.client.dataSocket.on('data', (chunk) => {
89
+ chunks.push(chunk);
90
+ this.client._debug(`Received ${chunk.length} bytes`);
91
+ });
92
+
93
+ this.client.dataSocket.on('error', reject);
94
+
95
+ this.client.dataSocket.on('close', () => {
96
+ // Wait for final 226 response
97
+ const finalHandler = (line) => {
98
+ const code = parseInt(line.substring(0, 3));
99
+ if (code === 226 || code === 250) {
100
+ this.client.removeListener('response', finalHandler);
101
+ const result = Buffer.concat(chunks);
102
+ this.client._debug(`Download completed: ${result.length} bytes`);
103
+ resolve(result);
104
+ } else if (code >= 400) {
105
+ this.client.removeListener('response', finalHandler);
106
+ reject(new Error(`FTP Error ${code}: ${line.substring(4)}`));
107
+ }
108
+ };
109
+ this.client.on('response', finalHandler);
110
+
111
+ // Timeout if no response
112
+ setTimeout(() => {
113
+ this.client.removeListener('response', finalHandler);
114
+ if (chunks.length > 0) {
115
+ resolve(Buffer.concat(chunks));
116
+ }
117
+ }, 5000);
118
+ });
119
+ });
120
+ }
121
+
122
+ /**
123
+ * List directory contents
124
+ * @param {string} [path='.'] - Directory path
125
+ * @returns {Promise<string>}
126
+ */
127
+ async list(path = '.') {
128
+ this.client._debug(`Listing directory: ${path}`);
129
+ const { host, port } = await this.connection.enterPassiveMode();
130
+
131
+ return new Promise((resolve, reject) => {
132
+ const chunks = [];
133
+ let commandSent = false;
134
+
135
+ this.client.dataSocket = net.createConnection({ host, port }, () => {
136
+ if (!commandSent) {
137
+ commandSent = true;
138
+ this.connection.sendCommand(`LIST ${path}`, true).catch(reject);
139
+ }
140
+ });
141
+
142
+ this.client.dataSocket.on('data', (chunk) => {
143
+ chunks.push(chunk);
144
+ });
145
+
146
+ this.client.dataSocket.on('error', reject);
147
+
148
+ this.client.dataSocket.on('close', () => {
149
+ // Wait for final 226 response
150
+ const finalHandler = (line) => {
151
+ const code = parseInt(line.substring(0, 3));
152
+ if (code === 226 || code === 250) {
153
+ this.client.removeListener('response', finalHandler);
154
+ resolve(Buffer.concat(chunks).toString('utf8'));
155
+ }
156
+ };
157
+ this.client.on('response', finalHandler);
158
+
159
+ // Timeout fallback
160
+ setTimeout(() => {
161
+ this.client.removeListener('response', finalHandler);
162
+ resolve(Buffer.concat(chunks).toString('utf8'));
163
+ }, 3000);
164
+ });
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Change working directory
170
+ * @param {string} path - Directory path
171
+ * @returns {Promise<void>}
172
+ */
173
+ async cd(path) {
174
+ await this.connection.sendCommand(`CWD ${path}`);
175
+ }
176
+
177
+ /**
178
+ * Get current working directory
179
+ * @returns {Promise<string>}
180
+ */
181
+ async pwd() {
182
+ const response = await this.connection.sendCommand('PWD');
183
+ const match = response.message.match(/"(.+)"/);
184
+ return match ? match[1] : '/';
185
+ }
186
+
187
+ /**
188
+ * Create directory
189
+ * @param {string} path - Directory path
190
+ * @returns {Promise<void>}
191
+ */
192
+ async mkdir(path) {
193
+ await this.connection.sendCommand(`MKD ${path}`);
194
+ }
195
+
196
+ /**
197
+ * Delete file
198
+ * @param {string} path - File path
199
+ * @returns {Promise<void>}
200
+ */
201
+ async delete(path) {
202
+ await this.connection.sendCommand(`DELE ${path}`);
203
+ }
204
+
205
+ /**
206
+ * Rename file
207
+ * @param {string} from - Current name
208
+ * @param {string} to - New name
209
+ * @returns {Promise<void>}
210
+ */
211
+ async rename(from, to) {
212
+ await this.connection.sendCommand(`RNFR ${from}`);
213
+ await this.connection.sendCommand(`RNTO ${to}`);
214
+ }
215
+
216
+ /**
217
+ * Get file size
218
+ * @param {string} path - File path
219
+ * @returns {Promise<number>}
220
+ */
221
+ async size(path) {
222
+ this.client._debug(`Getting size of ${path}`)
223
+ const response = await this.connection.sendCommand(`SIZE ${path}`);
224
+ return parseInt(response.message);
225
+ }
226
+
227
+ /**
228
+ * Check if file or directory exists
229
+ * @param {string} path - File or directory path
230
+ * @returns {Promise<boolean>}
231
+ */
232
+ async exists(path) {
233
+ try {
234
+ await this.size(path);
235
+ return true;
236
+ } catch (err) {
237
+ return false;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Ensure directory exists, creating it if necessary
243
+ * @param {string} dirPath - Directory path to ensure exists
244
+ * @param {boolean} recursive - Create parent directories if needed (default: true)
245
+ * @returns {Promise<void>}
246
+ */
247
+ async ensureDir(dirPath, recursive = true) {
248
+ this.client._debug(`Ensuring directory exists: ${dirPath}`);
249
+
250
+ // Normalize path
251
+ const normalized = normalizePath(dirPath);
252
+ if (normalized === '/' || normalized === '.') {
253
+ return; // Root or current directory always exists
254
+ }
255
+
256
+ // Try to cd to the directory
257
+ try {
258
+ await this.cd(normalized);
259
+ this.client._debug(`Directory already exists: ${normalized}`);
260
+ return;
261
+ } catch (err) {
262
+ this.client._debug(`Directory doesn't exist: ${normalized}`);
263
+ }
264
+
265
+ // If recursive, ensure parent directory exists first
266
+ if (recursive) {
267
+ const parentDir = normalized.substring(0, normalized.lastIndexOf('/')) || '/';
268
+ if (parentDir !== '/' && parentDir !== '.') {
269
+ await this.ensureDir(parentDir, true);
270
+ }
271
+ }
272
+
273
+ // Create the directory
274
+ try {
275
+ await this.mkdir(normalized);
276
+ this.client._debug(`Created directory: ${normalized}`);
277
+ } catch (err) {
278
+ // Ignore error if directory was created by another process
279
+ if (!err.message.includes('550') && !err.message.includes('exists')) {
280
+ throw err;
281
+ }
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Ensure parent directory exists for a file path
287
+ * @param {string} filePath - File path
288
+ * @returns {Promise<void>}
289
+ */
290
+ async ensureParentDir(filePath) {
291
+ const parentDir = getParentDir(filePath);
292
+ if (parentDir && parentDir !== '.' && parentDir !== '/') {
293
+ await this.ensureDir(parentDir);
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Upload file and ensure parent directory exists
299
+ * @param {string|Buffer} data - File data
300
+ * @param {string} remotePath - Remote file path
301
+ * @param {boolean} ensureDir - Ensure parent directory exists (default: false)
302
+ * @returns {Promise<void>}
303
+ */
304
+ async uploadFile(data, remotePath, ensureDir = false) {
305
+ if (ensureDir) {
306
+ await this.ensureParentDir(remotePath);
307
+ }
308
+ return this.upload(data, remotePath);
309
+ }
310
+
311
+ /**
312
+ * Get file modification time
313
+ * @param {string} path - File path
314
+ * @returns {Promise<Date>}
315
+ */
316
+ async modifiedTime(path) {
317
+ this.client._debug(`Getting modification time of ${path}`);
318
+ const response = await this.connection.sendCommand(`MDTM ${path}`);
319
+ return parseMdtmResponse(response.message);
320
+ }
321
+ }
322
+
323
+ module.exports = FTPCommands;
@@ -0,0 +1,176 @@
1
+ const net = require('net');
2
+
3
+ /**
4
+ * Handle FTP connection establishment and authentication
5
+ */
6
+ class FTPConnection {
7
+ constructor(client) {
8
+ this.client = client;
9
+ }
10
+
11
+ /**
12
+ * Connect to FTP server and authenticate
13
+ * @param {Object} options - Connection options
14
+ * @param {string} options.host - FTP server host
15
+ * @param {number} [options.port=21] - FTP server port
16
+ * @param {string} [options.user='anonymous'] - Username
17
+ * @param {string} [options.password='anonymous@'] - Password
18
+ * @returns {Promise<void>}
19
+ */
20
+ async connect({ host, port = 21, user = 'anonymous', password = 'anonymous@' }) {
21
+ this.client._debug(`Connecting to ${host}:${port} as ${user}`);
22
+
23
+ return new Promise((resolve, reject) => {
24
+ this.client.socket = net.createConnection({ host, port }, () => {
25
+ this.client.connected = true;
26
+ this.client._debug('TCP connection established');
27
+
28
+ if (this.client.keepAlive) {
29
+ this.client.socket.setKeepAlive(true, 10000);
30
+ }
31
+
32
+ this.client.emit('connected');
33
+ });
34
+
35
+ this.client.socket.setEncoding('utf8');
36
+ this.client.socket.on('data', async (data) => {
37
+ this.client.buffer += data;
38
+ const lines = this.client.buffer.split('\r\n');
39
+ this.client.buffer = lines.pop();
40
+
41
+ for (const line of lines) {
42
+ if (line) {
43
+ this.client._debug('<<<', line);
44
+ this.client.emit('response', line);
45
+ const code = parseInt(line.substring(0, 3));
46
+
47
+ // Handle initial connection
48
+ if (code === 220 && !this.client.authenticated) {
49
+ try {
50
+ this.client._debug('Authenticating...');
51
+ await this.sendCommand(`USER ${user}`);
52
+ await this.sendCommand(`PASS ${password}`);
53
+ this.client.authenticated = true;
54
+ this.client._debug('Authentication successful');
55
+ resolve();
56
+ } catch (err) {
57
+ reject(err);
58
+ }
59
+ }
60
+ }
61
+ }
62
+ });
63
+
64
+ this.client.socket.on('error', (err) => {
65
+ this.client.emit('error', err);
66
+ reject(err);
67
+ });
68
+
69
+ this.client.socket.on('close', () => {
70
+ this.client.connected = false;
71
+ this.client.authenticated = false;
72
+ this.client.emit('close');
73
+ });
74
+
75
+ setTimeout(() => reject(new Error('Connection timeout')), 10000);
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Send FTP command and wait for response
81
+ * @param {string} command - FTP command
82
+ * @param {boolean} allowPreliminary - Allow 1xx preliminary responses
83
+ * @returns {Promise<Object>}
84
+ */
85
+ sendCommand(command, allowPreliminary = false) {
86
+ const { maskPassword } = require('./utils');
87
+
88
+ return new Promise((resolve, reject) => {
89
+ if (!this.client.connected) {
90
+ return reject(new Error('Not connected'));
91
+ }
92
+
93
+ this.client._commandCount++;
94
+ this.client._lastCommand = command;
95
+ const cmdToLog = maskPassword(command);
96
+ this.client._debug('>>>', cmdToLog);
97
+
98
+ const timeoutId = setTimeout(() => {
99
+ this.client.removeListener('response', responseHandler);
100
+ reject(new Error(`Command timeout: ${cmdToLog}`));
101
+ }, this.client.timeout);
102
+
103
+ const responseHandler = (line) => {
104
+ clearTimeout(timeoutId);
105
+ const code = parseInt(line.substring(0, 3));
106
+ const message = line.substring(4);
107
+
108
+ // Check if this is a complete response (not a multi-line response in progress)
109
+ if (line.charAt(3) === ' ') {
110
+ // 1xx = Preliminary positive reply (command okay, another command expected)
111
+ // 2xx = Positive completion reply
112
+ // 3xx = Positive intermediate reply (command okay, awaiting more info)
113
+ // 4xx/5xx = Negative replies (errors)
114
+
115
+ if (code >= 100 && code < 200 && allowPreliminary) {
116
+ // Don't remove listener, wait for final response
117
+ this.client._debug('Preliminary response, waiting for completion...');
118
+ return;
119
+ }
120
+
121
+ clearTimeout(timeoutId);
122
+ this.client.removeListener('response', responseHandler);
123
+
124
+ if (code >= 200 && code < 400) {
125
+ resolve({ code, message, raw: line });
126
+ } else {
127
+ this.client._debug(`Error response: ${code}`);
128
+ reject(new Error(`FTP Error ${code}: ${message}`));
129
+ }
130
+ }
131
+ };
132
+
133
+ this.client.on('response', responseHandler);
134
+ this.client.socket.write(command + '\r\n');
135
+ });
136
+ }
137
+
138
+ /**
139
+ * Enter passive mode and get data connection info
140
+ * @returns {Promise<Object>}
141
+ */
142
+ async enterPassiveMode() {
143
+ const response = await this.sendCommand('PASV');
144
+ const match = response.message.match(/\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)/);
145
+
146
+ if (!match) {
147
+ throw new Error('Failed to parse PASV response');
148
+ }
149
+
150
+ const host = `${match[1]}.${match[2]}.${match[3]}.${match[4]}`;
151
+ const port = parseInt(match[5]) * 256 + parseInt(match[6]);
152
+
153
+ return { host, port };
154
+ }
155
+
156
+ /**
157
+ * Close connection
158
+ * @returns {Promise<void>}
159
+ */
160
+ async close() {
161
+ if (this.client.connected) {
162
+ this.client._debug('Closing connection...');
163
+ try {
164
+ await this.sendCommand('QUIT');
165
+ } catch (err) {
166
+ this.client._debug('Error during QUIT:', err.message);
167
+ }
168
+ this.client.socket.end();
169
+ this.client.connected = false;
170
+ this.client.authenticated = false;
171
+ this.client._debug('Connection closed');
172
+ }
173
+ }
174
+ }
175
+
176
+ module.exports = FTPConnection;
package/lib/utils.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * FTP Command helpers
3
+ * Utilities for building and parsing FTP commands
4
+ */
5
+
6
+ /**
7
+ * Parse PASV response to extract host and port
8
+ * @param {string} message - PASV response message
9
+ * @returns {Object} - { host, port }
10
+ */
11
+ function parsePasvResponse(message) {
12
+ const match = message.match(/\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)/);
13
+
14
+ if (!match) {
15
+ throw new Error('Failed to parse PASV response');
16
+ }
17
+
18
+ const host = `${match[1]}.${match[2]}.${match[3]}.${match[4]}`;
19
+ const port = parseInt(match[5]) * 256 + parseInt(match[6]);
20
+
21
+ return { host, port };
22
+ }
23
+
24
+ /**
25
+ * Parse MDTM response to Date object
26
+ * @param {string} message - MDTM response message
27
+ * @returns {Date}
28
+ */
29
+ function parseMdtmResponse(message) {
30
+ const match = message.match(/(\d{14})/);
31
+ if (!match) {
32
+ throw new Error('Failed to parse MDTM response');
33
+ }
34
+
35
+ const str = match[1];
36
+ const year = parseInt(str.substring(0, 4));
37
+ const month = parseInt(str.substring(4, 6)) - 1;
38
+ const day = parseInt(str.substring(6, 8));
39
+ const hour = parseInt(str.substring(8, 10));
40
+ const minute = parseInt(str.substring(10, 12));
41
+ const second = parseInt(str.substring(12, 14));
42
+
43
+ return new Date(Date.UTC(year, month, day, hour, minute, second));
44
+ }
45
+
46
+ /**
47
+ * Normalize FTP path
48
+ * @param {string} path - Path to normalize
49
+ * @returns {string}
50
+ */
51
+ function normalizePath(path) {
52
+ return path.replace(/\\/g, '/').replace(/\/+/g, '/');
53
+ }
54
+
55
+ /**
56
+ * Get parent directory path
57
+ * @param {string} filePath - File path
58
+ * @returns {string}
59
+ */
60
+ function getParentDir(filePath) {
61
+ const normalized = normalizePath(filePath);
62
+ const lastSlash = normalized.lastIndexOf('/');
63
+ if (lastSlash <= 0) {
64
+ return '/';
65
+ }
66
+ return normalized.substring(0, lastSlash);
67
+ }
68
+
69
+ /**
70
+ * Mask password in command for logging
71
+ * @param {string} command - FTP command
72
+ * @returns {string}
73
+ */
74
+ function maskPassword(command) {
75
+ return command.startsWith('PASS ') ? 'PASS ********' : command;
76
+ }
77
+
78
+ module.exports = {
79
+ parsePasvResponse,
80
+ parseMdtmResponse,
81
+ normalizePath,
82
+ getParentDir,
83
+ maskPassword
84
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "molex-ftp-client",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Lightweight FTP client using native Node.js TCP sockets (net module) with zero dependencies",
5
5
  "main": "index.js",
6
6
  "scripts": {