pure-js-sftp
Version:
A pure JavaScript SFTP client with revolutionary RSA-SHA2 compatibility fixes. Zero native dependencies, built on ssh2-streams with 100% SSH key support.
512 lines • 20.9 kB
JavaScript
"use strict";
/**
* Pure JS SFTP Client
* A pure JavaScript SFTP client with no native dependencies
* 100% API compatible with ssh2-sftp-client
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SSH2StreamsTransport = exports.SSH2StreamsSFTPClient = exports.SftpClient = void 0;
const ssh2_streams_client_1 = require("./sftp/ssh2-streams-client");
const types_1 = require("./ssh/types");
const fsPromises = __importStar(require("fs/promises"));
const path = __importStar(require("path"));
const events_1 = require("events");
class SftpClient extends events_1.EventEmitter {
constructor(_name) {
super();
this.client = null;
// name parameter for ssh2-sftp-client compatibility
}
// ssh2-sftp-client compatible connect method
async connect(config) {
this.client = new ssh2_streams_client_1.SSH2StreamsSFTPClient(config);
// Forward events
this.client.on('ready', () => this.emit('ready'));
this.client.on('error', (err) => this.emit('error', err));
this.client.on('close', () => this.emit('close'));
this.client.on('debug', (msg) => this.emit('debug', msg));
return this.client.connect();
}
// ssh2-sftp-client compatible API methods
async list(remotePath, filter) {
if (!this.client)
throw new Error('Not connected');
const entries = await this.client.listDirectory(remotePath);
// Convert DirectoryEntry[] to FileInfo[] for ssh2-sftp-client compatibility
const fileInfos = entries.map(entry => {
// Determine file type
let type = '-'; // default to file
if (entry.attrs.isDirectory?.()) {
type = 'd';
}
else if (entry.attrs.isSymbolicLink?.()) {
type = 'l';
}
// Parse permissions from mode
const mode = entry.attrs.permissions || 0;
const formatPermissions = (perm) => {
const r = (perm & 4) ? 'r' : '-';
const w = (perm & 2) ? 'w' : '-';
const x = (perm & 1) ? 'x' : '-';
return r + w + x;
};
const fileInfo = {
type,
name: entry.filename,
size: entry.attrs.size || 0,
modifyTime: entry.attrs.mtime || 0,
accessTime: entry.attrs.atime || 0,
rights: {
user: formatPermissions((mode >> 6) & 7),
group: formatPermissions((mode >> 3) & 7),
other: formatPermissions(mode & 7)
},
owner: entry.attrs.uid || 0,
group: entry.attrs.gid || 0
};
return fileInfo;
});
// Apply filter if provided
return filter ? fileInfos.filter(filter) : fileInfos;
}
async get(remotePath, dst) {
if (!this.client)
throw new Error('Not connected');
const handle = await this.client.openFile(remotePath, types_1.SFTP_OPEN_FLAGS.READ);
try {
// Read entire file into memory first
const chunks = [];
let offset = 0;
const chunkSize = 32768; // 32KB chunks
while (true) {
const data = await this.client.readFile(handle, offset, chunkSize);
if (!data || data.length === 0)
break;
chunks.push(data);
offset += data.length;
if (data.length < chunkSize)
break; // End of file
}
const fileBuffer = Buffer.concat(chunks);
if (dst === undefined) {
// No destination specified - return Buffer
return fileBuffer;
}
else if (typeof dst === 'string') {
// String destination - write to local file
await fsPromises.writeFile(dst, fileBuffer);
return dst;
}
else {
// Writable stream destination
return new Promise((resolve, reject) => {
dst.write(fileBuffer, (error) => {
if (error) {
reject(error);
}
else {
dst.end();
resolve(dst);
}
});
});
}
}
finally {
await this.client.closeFile(handle);
}
}
async put(input, remotePath, options) {
if (!this.client)
throw new Error('Not connected');
const handle = await this.client.openFile(remotePath, types_1.SFTP_OPEN_FLAGS.WRITE | types_1.SFTP_OPEN_FLAGS.CREAT | types_1.SFTP_OPEN_FLAGS.TRUNC);
try {
let dataBuffer;
if (Buffer.isBuffer(input)) {
// Input is already a Buffer
dataBuffer = input;
}
else if (typeof input === 'string') {
// Input is a file path - read the file
dataBuffer = await fsPromises.readFile(input);
}
else {
// Input is a Readable stream - collect all data
const chunks = [];
await new Promise((resolve, reject) => {
input.on('data', (chunk) => {
chunks.push(chunk);
});
input.on('end', () => {
resolve();
});
input.on('error', (error) => {
reject(error);
});
});
dataBuffer = Buffer.concat(chunks);
}
// Write data in chunks
let offset = 0;
const chunkSize = 32768; // 32KB chunks
while (offset < dataBuffer.length) {
const end = Math.min(offset + chunkSize, dataBuffer.length);
const chunk = dataBuffer.subarray(offset, end);
await this.client.writeFile(handle, offset, chunk);
offset += chunk.length;
}
return remotePath;
}
finally {
await this.client.closeFile(handle);
}
}
async delete(remotePath) {
if (!this.client)
throw new Error('Not connected');
return this.client.removeFile(remotePath);
}
async rename(oldPath, newPath) {
if (!this.client)
throw new Error('Not connected');
return this.client.renameFile(oldPath, newPath);
}
async mkdir(remotePath, recursive = false) {
if (!this.client)
throw new Error('Not connected');
if (recursive) {
// Normalize path and split into parts
const normalizedPath = remotePath.replace(/\/+/g, '/'); // Remove duplicate slashes
const parts = normalizedPath.split('/').filter(p => p);
let currentPath = normalizedPath.startsWith('/') ? '' : '';
for (const part of parts) {
currentPath = currentPath ? `${currentPath}/${part}` : (normalizedPath.startsWith('/') ? `/${part}` : part);
try {
// Check if directory already exists first
const stats = await this.client.stat(currentPath);
if (stats.isDirectory?.()) {
continue; // Directory exists, skip
}
else {
throw new Error(`Path exists but is not a directory: ${currentPath}`);
}
}
catch (error) {
// Use SFTP status codes instead of error message parsing
const isNotFound = error instanceof Error && error.name === 'SFTPError' &&
error.code === 2; // SFTP_STATUS.NO_SUCH_FILE
if (isNotFound || error.message?.includes('No such file') || error.message?.includes('not a directory')) {
try {
await this.client.makeDirectory(currentPath);
}
catch (createError) {
// Only ignore if directory was created by another process
const alreadyExists = createError instanceof Error && createError.name === 'SFTPError' &&
createError.code === 4; // SFTP_STATUS.FAILURE for existing dir
if (!alreadyExists && !createError.message?.includes('File exists')) {
throw new Error(`Failed to create directory ${currentPath}: ${createError.message}`);
}
}
}
else {
throw error;
}
}
}
}
else {
return this.client.makeDirectory(remotePath);
}
}
async rmdir(remotePath, recursive = false) {
if (!this.client)
throw new Error('Not connected');
if (recursive) {
// First check if directory exists and is actually a directory
try {
const stats = await this.client.stat(remotePath);
if (!stats.isDirectory?.()) {
throw new Error(`Path is not a directory: ${remotePath}`);
}
}
catch (error) {
const isNotFound = error instanceof Error && error.name === 'SFTPError' &&
error.code === 2; // SFTP_STATUS.NO_SUCH_FILE
if (isNotFound || error.message?.includes('No such file')) {
return; // Directory doesn't exist, nothing to remove
}
throw error;
}
// List directory contents and remove recursively
try {
const entries = await this.client.listDirectory(remotePath);
for (const entry of entries) {
if (entry.filename === '.' || entry.filename === '..')
continue;
// Normalize path construction
const fullPath = remotePath.endsWith('/')
? `${remotePath}${entry.filename}`
: `${remotePath}/${entry.filename}`;
if (entry.attrs.isDirectory?.()) {
await this.rmdir(fullPath, true);
}
else {
await this.client.removeFile(fullPath);
}
}
}
catch (error) {
// If we can't list the directory, it might be empty or permission denied
const isNotFound = error instanceof Error && error.name === 'SFTPError' &&
error.code === 2; // SFTP_STATUS.NO_SUCH_FILE
if (!isNotFound && !error.message?.includes('No such file')) {
throw new Error(`Failed to list directory contents: ${error.message}`);
}
}
}
return this.client.removeDirectory(remotePath);
}
async exists(remotePath) {
if (!this.client)
throw new Error('Not connected');
try {
const stats = await this.client.stat(remotePath);
// Determine file type based on attributes
if (stats.isDirectory?.()) {
return 'd';
}
else if (stats.isSymbolicLink?.()) {
return 'l';
}
else {
return '-'; // regular file
}
}
catch (error) {
return false;
}
}
async stat(remotePath) {
if (!this.client)
throw new Error('Not connected');
return this.client.stat(remotePath);
}
// Fast transfer methods (optimized versions)
async fastGet(remotePath, localPath, options) {
if (!this.client)
throw new Error('Not connected');
// For now, use regular get - can be optimized later with parallel streams
await this.get(remotePath, localPath);
return localPath;
}
async fastPut(localPath, remotePath, options) {
if (!this.client)
throw new Error('Not connected');
// For now, use regular put - can be optimized later with parallel streams
await this.put(localPath, remotePath);
return remotePath;
}
async append(input, remotePath, options) {
if (!this.client)
throw new Error('Not connected');
const data = Buffer.isBuffer(input) ? input : Buffer.from(input, 'utf8');
// For append, we need to get current file size to know where to write
let fileSize = 0;
try {
const stats = await this.client.stat(remotePath);
fileSize = stats.size || 0;
}
catch (error) {
// File doesn't exist, start at 0
if (!(error.message?.includes('No such file') || error.message?.includes('not found'))) {
throw error;
}
}
const handle = await this.client.openFile(remotePath, types_1.SFTP_OPEN_FLAGS.WRITE | types_1.SFTP_OPEN_FLAGS.CREAT);
try {
// Write at end of file using actual file size
await this.client.writeFile(handle, fileSize, data);
}
finally {
await this.client.closeFile(handle);
}
return remotePath;
}
async chmod(remotePath, mode) {
if (!this.client)
throw new Error('Not connected');
const numericMode = typeof mode === 'string' ? parseInt(mode, 8) : mode;
return this.client.setAttributes(remotePath, { permissions: numericMode });
}
async realPath(remotePath) {
if (!this.client)
throw new Error('Not connected');
return this.client.realPath(remotePath);
}
async uploadDir(srcDir, dstDir, options) {
if (!this.client)
throw new Error('Not connected');
// Verify source directory exists
try {
const srcStats = await fsPromises.stat(srcDir);
if (!srcStats.isDirectory()) {
throw new Error(`Source path is not a directory: ${srcDir}`);
}
}
catch (error) {
throw new Error(`Source directory not accessible: ${srcDir} - ${error.message}`);
}
// Create destination directory if it doesn't exist
try {
await this.mkdir(dstDir, true);
}
catch (error) {
// Only ignore if directory already exists
if (!error.message?.includes('File exists')) {
throw new Error(`Failed to create destination directory: ${dstDir} - ${error.message}`);
}
}
const entries = await fsPromises.readdir(srcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(srcDir, entry.name);
const dstPath = `${dstDir}/${entry.name}`;
// Apply filter if provided
if (options?.filter && !options.filter(srcPath, entry.isDirectory())) {
continue;
}
try {
if (entry.isDirectory()) {
await this.uploadDir(srcPath, dstPath, options);
}
else {
await this.put(srcPath, dstPath);
}
}
catch (error) {
throw new Error(`Failed to upload ${srcPath}: ${error.message}`);
}
}
}
async downloadDir(srcDir, dstDir, options) {
if (!this.client)
throw new Error('Not connected');
// Verify source directory exists on remote
try {
const srcStats = await this.client.stat(srcDir);
if (!srcStats.isDirectory?.()) {
throw new Error(`Remote path is not a directory: ${srcDir}`);
}
}
catch (error) {
throw new Error(`Remote directory not accessible: ${srcDir} - ${error.message}`);
}
// Create local destination directory if it doesn't exist
try {
await fsPromises.mkdir(dstDir, { recursive: true });
}
catch (error) {
throw new Error(`Failed to create local directory: ${dstDir} - ${error.message}`);
}
const entries = await this.client.listDirectory(srcDir);
for (const entry of entries) {
if (entry.filename === '.' || entry.filename === '..')
continue;
const srcPath = `${srcDir}/${entry.filename}`;
const dstPath = path.join(dstDir, entry.filename);
// Apply filter if provided
if (options?.filter && !options.filter(srcPath, entry.attrs.isDirectory?.() || false)) {
continue;
}
try {
if (entry.attrs.isDirectory?.()) {
await this.downloadDir(srcPath, dstPath, options);
}
else {
await this.get(srcPath, dstPath);
}
}
catch (error) {
throw new Error(`Failed to download ${srcPath}: ${error.message}`);
}
}
}
// Alias for ssh2-sftp-client compatibility
async end() {
this.disconnect();
}
// Low-level methods (for advanced users)
async listDirectory(path) {
if (!this.client)
throw new Error('Not connected');
return this.client.listDirectory(path);
}
async openFile(path, flags) {
if (!this.client)
throw new Error('Not connected');
return this.client.openFile(path, flags);
}
async closeFile(handle) {
if (!this.client)
throw new Error('Not connected');
return this.client.closeFile(handle);
}
async readFile(handle, offset, length) {
if (!this.client)
throw new Error('Not connected');
return this.client.readFile(handle, offset, length);
}
disconnect() {
if (this.client) {
this.client.disconnect();
this.client = null;
}
}
isReady() {
return this.client ? this.client.isReady() : false;
}
}
exports.SftpClient = SftpClient;
exports.default = SftpClient;
// Export types and classes for TypeScript users
__exportStar(require("./ssh/types"), exports);
var ssh2_streams_client_2 = require("./sftp/ssh2-streams-client");
Object.defineProperty(exports, "SSH2StreamsSFTPClient", { enumerable: true, get: function () { return ssh2_streams_client_2.SSH2StreamsSFTPClient; } });
var ssh2_streams_transport_1 = require("./ssh/ssh2-streams-transport");
Object.defineProperty(exports, "SSH2StreamsTransport", { enumerable: true, get: function () { return ssh2_streams_transport_1.SSH2StreamsTransport; } });
//# sourceMappingURL=index.js.map