refactor: pivot from ssh proxy to distributed api model

- Delete SSHFileAdapter, IFileAdapter, LocalFileAdapter (SSH/SFTP stack)
- Remove ssh2, ssh2-sftp-client dependencies; add http-proxy-middleware, http-proxy
- NodeRegistry: remote nodes no longer use Dockerode TCP; new getProxyTarget() returns {apiUrl, apiToken}
- NodeRegistry.testConnection: remote nodes use HTTP GET /api/auth/check instead of docker.info()
- DatabaseService: Node interface swaps SSH/TLS fields for api_url + api_token; legacy columns preserved for DB compat
- FileSystemService: reverted to clean local-only fs.promises; adapter pattern fully removed
- ComposeService: executeRemote() and SSH log streaming deleted; local-only execution remains
- index.ts: add /api/auth/generate-node-token endpoint (long-lived JWT, scope:node_proxy)
- index.ts: authMiddleware now accepts Bearer token in addition to cookie (Sencho-to-Sencho auth)
- index.ts: remote HTTP proxy middleware intercepts all /api/ requests for remote nodes, strips x-node-id, injects Authorization header, proxies to api_url
- index.ts: WS upgrade handler proxies WebSocket connections for remote nodes via http-proxy wsProxyServer
- NodeManager.tsx: form reduced to Name, API URL, API Token; Generate Node Token button added inline
- NodeContext.tsx: Node interface updated to api_url/api_token

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SaelixCode
2026-03-19 12:20:32 -04:00
parent 23730430d7
commit c91c9ed7fd
14 changed files with 522 additions and 899 deletions
+99 -26
View File
@@ -12,6 +12,8 @@ import crypto from 'crypto';
import composerize from 'composerize';
import si from 'systeminformation';
import http from 'http';
import httpProxy from 'http-proxy';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { spawn, exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
@@ -92,9 +94,21 @@ declare global {
}
}
// WebSocket proxy server for forwarding remote node WS connections
const wsProxyServer = httpProxy.createProxyServer({ changeOrigin: true });
wsProxyServer.on('error', (err, _req, socket: any) => {
console.error('[WS Proxy] Error:', err.message);
try { socket?.destroy(); } catch {}
});
// Authentication Middleware
// Accepts both cookie auth (browser sessions) and Bearer token auth (Sencho-to-Sencho proxy)
const authMiddleware = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const token = req.cookies[COOKIE_NAME];
const cookieToken = req.cookies[COOKIE_NAME];
const bearerToken = req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice(7)
: null;
const token = cookieToken || bearerToken;
if (!token) {
res.status(401).json({ error: 'Authentication required' });
@@ -105,8 +119,9 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) throw new Error('No JWT secret');
const decoded = jwt.verify(token, jwtSecret) as { username: string };
req.user = { username: decoded.username };
const decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string };
// Accept both user sessions and node proxy tokens
req.user = { username: decoded.username || 'node-proxy' };
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
@@ -263,6 +278,23 @@ app.get('/api/auth/check', authMiddleware, (req: Request, res: Response): void =
res.json({ authenticated: true, user: req.user });
});
// Generate a long-lived node proxy token for Sencho-to-Sencho authentication
app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, res: Response): Promise<void> => {
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) {
res.status(500).json({ error: 'No JWT secret configured on this instance.' });
return;
}
// No expiry — this token is managed by the admin who pastes it into the main dashboard
const token = jwt.sign({ scope: 'node_proxy' }, jwtSecret);
res.json({ token });
} catch (error: any) {
res.status(500).json({ error: error.message || 'Failed to generate node token' });
}
});
// Apply authentication middleware to all /api/* routes except /api/auth/*
app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
if (req.path.startsWith('/auth/')) {
@@ -272,6 +304,48 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
authMiddleware(req, res, next);
});
// Remote Node HTTP Proxy
// Intercepts all /api/ requests for remote Distributed API nodes and forwards them
// to the target Sencho instance. Node management and auth routes always execute locally.
app.use('/api/', (req: Request, res: Response, next: NextFunction): void => {
if (req.path.startsWith('/auth/') || req.path.startsWith('/nodes')) {
next();
return;
}
const node = NodeRegistry.getInstance().getNode(req.nodeId);
if (!node || node.type !== 'remote') {
next();
return;
}
if (!node.api_url || !node.api_token) {
res.status(503).json({
error: `Remote node "${node.name}" has no API URL or token configured. Update it in Settings → Nodes.`
});
return;
}
createProxyMiddleware<Request, Response>({
target: node.api_url,
changeOrigin: true,
on: {
proxyReq: (proxyReq) => {
// Remote Sencho is always "local" to itself — strip node context
proxyReq.removeHeader('x-node-id');
proxyReq.setHeader('Authorization', `Bearer ${node.api_token!}`);
},
error: (_err, _req, proxyRes) => {
if (!(proxyRes as Response).headersSent) {
(proxyRes as Response).status(502).json({
error: `Remote node "${node.name}" is unreachable. Check the API URL and ensure Sencho is running on that host.`
});
}
},
},
})(req, res, next);
});
// Create HTTP server for WebSocket upgrade handling
const server = http.createServer(app);
@@ -302,11 +376,25 @@ server.on('upgrade', async (req, socket, head) => {
if (!jwtSecret) throw new Error('No JWT secret');
jwt.verify(token, jwtSecret);
// Check if this is a stack logs WebSocket request
const url = req.url || '';
const parsedUrl = new URL(url, `http://${req.headers.host || 'localhost'}`);
const pathname = parsedUrl.pathname;
// Resolve node context from query param
const nodeIdParam = parsedUrl.searchParams.get('nodeId');
const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId();
const node = NodeRegistry.getInstance().getNode(nodeId);
// Remote Node WebSocket Proxy — forward the entire WS connection to the remote Sencho instance
if (node && node.type === 'remote' && node.api_url && node.api_token) {
const wsTarget = node.api_url.replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws');
req.headers['authorization'] = `Bearer ${node.api_token}`;
delete req.headers['x-node-id'];
wsProxyServer.ws(req, socket, head, { target: wsTarget });
return;
}
// Local node handling
const logsMatch = pathname.match(/^\/api\/stacks\/([^/]+)\/logs$/);
const hostConsoleMatch = pathname.match(/^\/api\/system\/host-console/);
@@ -315,9 +403,6 @@ server.on('upgrade', async (req, socket, head) => {
const logsWss = new WebSocket.Server({ noServer: true });
logsWss.handleUpgrade(req, socket, head, (ws) => {
const stackName = decodeURIComponent(logsMatch[1]);
const nodeIdParam = parsedUrl.searchParams.get('nodeId');
const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId();
try {
ComposeService.getInstance(nodeId).streamLogs(stackName, ws);
} catch (error) {
@@ -332,17 +417,12 @@ server.on('upgrade', async (req, socket, head) => {
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
let targetDirectory = '';
try {
const reqUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
const nodeIdParam = reqUrl.searchParams.get('nodeId');
const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId();
targetDirectory = FileSystemService.getInstance(nodeId).getBaseDir();
const stackParam = reqUrl.searchParams.get('stack');
const stackParam = parsedUrl.searchParams.get('stack');
if (stackParam) {
targetDirectory = path.join(targetDirectory, stackParam);
}
} catch (e) {
// ignore parsing error, fallback to base dir
targetDirectory = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()).getBaseDir();
}
try {
@@ -1432,7 +1512,7 @@ app.get('/api/nodes/:id', async (req: Request, res: Response) => {
// Create a new node
app.post('/api/nodes', async (req: Request, res: Response) => {
try {
const { name, type, host, port, ssh_port, compose_dir, is_default, ssh_user, ssh_password, ssh_key, tls_ca, tls_cert, tls_key } = req.body;
const { name, type, compose_dir, is_default, api_url, api_token } = req.body;
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Node name is required' });
@@ -1440,24 +1520,17 @@ app.post('/api/nodes', async (req: Request, res: Response) => {
if (!type || !['local', 'remote'].includes(type)) {
return res.status(400).json({ error: 'Node type must be "local" or "remote"' });
}
if (type === 'remote' && (!host || typeof host !== 'string')) {
return res.status(400).json({ error: 'Host is required for remote nodes' });
if (type === 'remote' && (!api_url || typeof api_url !== 'string')) {
return res.status(400).json({ error: 'API URL is required for remote nodes' });
}
const id = DatabaseService.getInstance().addNode({
name,
type,
host: host || '',
port: port || 2375,
ssh_port: ssh_port || 22,
compose_dir: compose_dir || '/opt/docker',
compose_dir: compose_dir || '/app/compose',
is_default: is_default || false,
ssh_user: ssh_user || '',
ssh_password: ssh_password || '',
ssh_key: ssh_key || '',
tls_ca: tls_ca || '',
tls_cert: tls_cert || '',
tls_key: tls_key || '',
api_url: api_url || '',
api_token: api_token || '',
});
res.json({ success: true, id });
+36 -150
View File
@@ -1,14 +1,16 @@
import { spawn, exec } from 'child_process';
import { promisify } from 'util';
import { spawn } from 'child_process';
import path from 'path';
import WebSocket from 'ws';
import DockerController from './DockerController';
import { LogFormatter } from './LogFormatter';
import { NodeRegistry } from './NodeRegistry';
import { Client as SSHClient } from 'ssh2';
const execAsync = promisify(exec);
/**
* ComposeService — local docker compose CLI execution.
*
* In the Distributed API model, remote node compose operations are handled
* by the remote Sencho instance. This service only executes commands locally.
*/
export class ComposeService {
private baseDir: string;
private nodeId: number;
@@ -22,30 +24,10 @@ export class ComposeService {
return new ComposeService(nodeId);
}
/**
* Universal command execution (Local or Remote SSH)
*/
private async executeCommand(
command: string,
args: string[],
cwd: string,
ws?: WebSocket,
throwOnError = true
): Promise<void> {
const node = NodeRegistry.getInstance().getNode(this.nodeId);
if (!node) throw new Error(`Node ${this.nodeId} not found`);
if (node.type === 'local' || !node.host) {
return this.executeLocal(command, args, cwd, ws, throwOnError);
} else {
return this.executeRemote(node, command, args, cwd, ws, throwOnError);
}
}
private async executeLocal(
command: string,
args: string[],
cwd: string,
private execute(
command: string,
args: string[],
cwd: string,
ws?: WebSocket,
throwOnError = true
): Promise<void> {
@@ -90,65 +72,9 @@ export class ComposeService {
});
}
private async executeRemote(
node: any,
command: string,
args: string[],
cwd: string,
ws?: WebSocket,
throwOnError = true
): Promise<void> {
return new Promise((resolve, reject) => {
const conn = new SSHClient();
let errorLog = '';
conn.on('ready', () => {
const cmdString = `cd "${cwd}" && ${command} ${args.map(a => `"${a}"`).join(' ')}`;
conn.exec(cmdString, (err, stream) => {
if (err) {
conn.end();
if (throwOnError) reject(err); else resolve();
return;
}
const onData = (data: any) => {
const text = data.toString();
errorLog += text;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(text);
}
};
stream.on('data', onData).stderr.on('data', onData);
stream.on('close', (code: any, signal: any) => {
conn.end();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(`Command exited with code ${code}\n`);
}
if (code === 0) resolve();
else if (throwOnError) reject(new Error(errorLog.trim() || `Command failed with code ${code}`));
else resolve();
});
});
}).on('error', (err) => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(`SSH Error: ${err.message}\n`);
if (throwOnError) reject(err); else resolve();
}).connect({
host: node.host,
port: node.ssh_port || 22,
username: node.ssh_user!,
password: node.ssh_password,
privateKey: node.ssh_key,
readyTimeout: 10000,
});
});
}
async runCommand(stackName: string, action: 'down' | 'start' | 'stop' | 'restart', ws?: WebSocket): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
await this.executeCommand('docker', ['compose', action], stackDir, ws);
await this.execute('docker', ['compose', action], stackDir, ws);
}
async deployStack(stackName: string, ws?: WebSocket): Promise<void> {
@@ -165,7 +91,7 @@ export class ComposeService {
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
}
await this.executeCommand('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
// Post-Deploy Health Probe
await new Promise(resolve => setTimeout(resolve, 3000));
@@ -231,13 +157,10 @@ export class ComposeService {
let activeProcesses = 0;
let streamEndedHandled = false;
let clientConnections: any[] = [];
let localProcesses: ReturnType<typeof spawn>[] = [];
const onWsClose = () => {
localProcesses.forEach(cp => { try { cp.kill(); } catch {} });
clientConnections.forEach(conn => { try { conn.end(); } catch {} });
};
ws.on('close', onWsClose);
@@ -253,80 +176,43 @@ export class ComposeService {
}
};
const node = NodeRegistry.getInstance().getNode(this.nodeId);
const isRemote = (node && node.type === 'remote' && node.host);
for (const container of containersToLog) {
const containerName = container.Names?.[0]?.replace(/^\//, '') || container.Id;
activeProcesses++;
let lineBuffer = '';
const sendOutput = (data: Buffer | any) => {
const sendOutput = (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
lineBuffer += data.toString();
const lines = lineBuffer.split(/\r?\n/);
lineBuffer = lines.pop() || '';
for (const line of lines) {
const formattedLine = LogFormatter.process(line);
ws.send(formattedLine + '\r\n');
ws.send(LogFormatter.process(line) + '\r\n');
}
}
};
const flushBuffer = () => {
if (lineBuffer && ws.readyState === WebSocket.OPEN) {
const formattedLine = LogFormatter.process(lineBuffer);
ws.send(formattedLine + '\r\n');
lineBuffer = '';
if (lineBuffer && ws.readyState === WebSocket.OPEN) {
ws.send(LogFormatter.process(lineBuffer) + '\r\n');
lineBuffer = '';
}
}
};
if (isRemote) {
const conn = new SSHClient();
clientConnections.push(conn);
conn.on('ready', () => {
conn.exec(`docker logs -f --tail 100 "${containerName}"`, {
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
}, (err, stream) => {
if (err) {
conn.end();
handleProcessEnd();
return;
}
stream.on('data', sendOutput).stderr.on('data', sendOutput);
stream.on('close', () => {
flushBuffer();
conn.end();
handleProcessEnd();
});
});
}).on('error', handleProcessEnd).connect({
host: node!.host,
port: node!.ssh_port || 22,
username: node!.ssh_user!,
password: node!.ssh_password,
privateKey: node!.ssh_key,
readyTimeout: 10000,
});
} else {
const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], {
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
localProcesses.push(child);
child.stdout.on('data', sendOutput);
child.stderr.on('data', sendOutput);
child.on('error', handleProcessEnd);
child.on('close', () => {
flushBuffer();
handleProcessEnd();
});
}
const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], {
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
localProcesses.push(child);
child.stdout.on('data', sendOutput);
child.stderr.on('data', sendOutput);
child.on('error', handleProcessEnd);
child.on('close', () => {
flushBuffer();
handleProcessEnd();
});
}
} catch (err) {
if (!isClosed && ws.readyState === WebSocket.OPEN) {
@@ -360,17 +246,17 @@ export class ComposeService {
}
sendOutput('=== Pulling latest images ===\n');
await this.executeCommand('docker', ['compose', 'pull'], stackDir, ws);
await this.execute('docker', ['compose', 'pull'], stackDir, ws);
sendOutput('=== Recreating containers ===\n');
await this.executeCommand('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
sendOutput('=== Stack updated successfully ===\n');
}
public async downStack(stackName: string): Promise<void> {
const stackPath = path.join(this.baseDir, stackName);
try {
await this.executeCommand('docker', ['compose', 'down'], stackPath, undefined, false);
await this.execute('docker', ['compose', 'down'], stackPath, undefined, false);
} catch (error) {
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`);
}
+25 -47
View File
@@ -29,19 +29,12 @@ export interface Node {
id: number;
name: string;
type: 'local' | 'remote';
host: string;
port: number;
ssh_port: number;
compose_dir: string;
is_default: boolean;
status: 'online' | 'offline' | 'unknown';
created_at: number;
ssh_user?: string;
ssh_password?: string;
ssh_key?: string;
tls_ca?: string;
tls_cert?: string;
tls_key?: string;
api_url?: string;
api_token?: string;
}
export interface NotificationHistory {
@@ -59,14 +52,12 @@ export class DatabaseService {
private constructor() {
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
// Ensure data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'sencho.db');
this.db = new Database(dbPath);
// Default journal mode is safer for arbitrary Docker volume mounts than WAL
this.initSchema();
this.migrateJsonConfig(dataDir);
@@ -126,7 +117,7 @@ export class DatabaseService {
net_tx_mb REAL NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON container_metrics(timestamp);
CREATE INDEX IF NOT EXISTS idx_metrics_container ON container_metrics(container_id);
@@ -134,8 +125,6 @@ export class DatabaseService {
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL DEFAULT 'local',
host TEXT NOT NULL DEFAULT '',
port INTEGER NOT NULL DEFAULT 2375,
compose_dir TEXT NOT NULL DEFAULT '/app/compose',
is_default INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'unknown',
@@ -147,6 +136,14 @@ export class DatabaseService {
const maybeAddCol = (table: string, col: string, def: string) => {
try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch (e) { /* ignore */ }
};
// Distributed API model columns
maybeAddCol('nodes', 'api_url', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'api_token', "TEXT DEFAULT ''");
// Legacy SSH/TLS columns preserved for DB backward-compat (no longer read or written)
maybeAddCol('nodes', 'host', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'port', 'INTEGER DEFAULT 2375');
maybeAddCol('nodes', 'ssh_port', 'INTEGER DEFAULT 22');
maybeAddCol('nodes', 'ssh_user', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'ssh_password', "TEXT DEFAULT ''");
@@ -162,15 +159,15 @@ export class DatabaseService {
stmt.run('host_disk_limit', '90');
stmt.run('global_crash', '1');
stmt.run('docker_janitor_gb', '5');
stmt.run('global_logs_refresh', '5'); // Default 5 seconds
stmt.run('developer_mode', '0'); // Default off
stmt.run('global_logs_refresh', '5');
stmt.run('developer_mode', '0');
// Seed the default local node if none exists
const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
if (nodeCount === 0) {
this.db.prepare(
'INSERT INTO nodes (name, type, host, port, compose_dir, is_default, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run('Local', 'local', '', 0, process.env.COMPOSE_DIR || '/app/compose', 1, 'online', Date.now());
'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, ?, ?, ?, ?, ?)'
).run('Local', 'local', process.env.COMPOSE_DIR || '/app/compose', 1, 'online', Date.now());
}
}
@@ -188,8 +185,6 @@ export class DatabaseService {
stmt.run('auth_jwt_secret', config.jwtSecret);
console.log('Successfully migrated sencho.json credentials to SQLite global_settings.');
// Delete the file after migrating
fs.unlinkSync(configPath);
}
} catch (err) {
@@ -295,9 +290,8 @@ export class DatabaseService {
const stmt = this.db.prepare('INSERT INTO notification_history (level, message, timestamp, is_read) VALUES (?, ?, ?, 0)');
stmt.run(notification.level, notification.message, notification.timestamp);
// Cleanup old notifications (keep last 100)
this.db.exec(`
DELETE FROM notification_history
DELETE FROM notification_history
WHERE id NOT IN (
SELECT id FROM notification_history ORDER BY timestamp DESC LIMIT 100
)
@@ -343,7 +337,7 @@ export class DatabaseService {
// --- Nodes ---
public getNodes(): Node[] {
const stmt = this.db.prepare('SELECT * FROM nodes ORDER BY is_default DESC, name ASC');
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes ORDER BY is_default DESC, name ASC');
return stmt.all().map((row: any) => ({
...row,
is_default: row.is_default === 1
@@ -351,43 +345,35 @@ export class DatabaseService {
}
public getNode(id: number): Node | undefined {
const stmt = this.db.prepare('SELECT * FROM nodes WHERE id = ?');
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes WHERE id = ?');
const row = stmt.get(id) as any;
if (!row) return undefined;
return { ...row, is_default: row.is_default === 1 };
}
public getDefaultNode(): Node | undefined {
const stmt = this.db.prepare('SELECT * FROM nodes WHERE is_default = 1 LIMIT 1');
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes WHERE is_default = 1 LIMIT 1');
const row = stmt.get() as any;
if (!row) return undefined;
return { ...row, is_default: row.is_default === 1 };
}
public addNode(node: Omit<Node, 'id' | 'status' | 'created_at'>): number {
// If this node is set as default, clear other defaults first
if (node.is_default) {
this.db.prepare('UPDATE nodes SET is_default = 0').run();
}
const stmt = this.db.prepare(
'INSERT INTO nodes (name, type, host, port, ssh_port, compose_dir, is_default, status, created_at, ssh_user, ssh_password, ssh_key, tls_ca, tls_cert, tls_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at, api_url, api_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
const result = stmt.run(
node.name,
node.type,
node.host,
node.port,
node.ssh_port || 22,
node.compose_dir,
node.compose_dir || '/app/compose',
node.is_default ? 1 : 0,
'unknown',
Date.now(),
node.ssh_user || '',
node.ssh_password || '',
node.ssh_key || '',
node.tls_ca || '',
node.tls_cert || '',
node.tls_key || ''
node.api_url || '',
node.api_token || ''
);
return result.lastInsertRowid as number;
}
@@ -396,7 +382,6 @@ export class DatabaseService {
const node = this.getNode(id);
if (!node) throw new Error(`Node with id ${id} not found`);
// If setting as default, clear other defaults first
if (updates.is_default) {
this.db.prepare('UPDATE nodes SET is_default = 0').run();
}
@@ -406,18 +391,11 @@ export class DatabaseService {
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if (updates.type !== undefined) { fields.push('type = ?'); values.push(updates.type); }
if (updates.host !== undefined) { fields.push('host = ?'); values.push(updates.host); }
if (updates.port !== undefined) { fields.push('port = ?'); values.push(updates.port); }
if (updates.ssh_port !== undefined) { fields.push('ssh_port = ?'); values.push(updates.ssh_port); }
if (updates.compose_dir !== undefined) { fields.push('compose_dir = ?'); values.push(updates.compose_dir); }
if (updates.is_default !== undefined) { fields.push('is_default = ?'); values.push(updates.is_default ? 1 : 0); }
if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); }
if (updates.ssh_user !== undefined) { fields.push('ssh_user = ?'); values.push(updates.ssh_user); }
if (updates.ssh_password !== undefined) { fields.push('ssh_password = ?'); values.push(updates.ssh_password); }
if (updates.ssh_key !== undefined) { fields.push('ssh_key = ?'); values.push(updates.ssh_key); }
if (updates.tls_ca !== undefined) { fields.push('tls_ca = ?'); values.push(updates.tls_ca); }
if (updates.tls_cert !== undefined) { fields.push('tls_cert = ?'); values.push(updates.tls_cert); }
if (updates.tls_key !== undefined) { fields.push('tls_key = ?'); values.push(updates.tls_key); }
if (updates.api_url !== undefined) { fields.push('api_url = ?'); values.push(updates.api_url); }
if (updates.api_token !== undefined) { fields.push('api_token = ?'); values.push(updates.api_token); }
if (fields.length === 0) return;
+45 -123
View File
@@ -1,81 +1,59 @@
import path from 'path';
import { IFileAdapter } from './fs/IFileAdapter';
import { LocalFileAdapter } from './fs/LocalFileAdapter';
import { SSHFileAdapter } from './fs/SSHFileAdapter';
import fs from 'fs';
import { promises as fsPromises } from 'fs';
import { NodeRegistry } from './NodeRegistry';
/**
* FileSystemService — local-only file I/O for compose stack management.
*
* In the Distributed API model, remote node file operations are handled
* by the remote Sencho instance itself. This service only operates on
* the local filesystem.
*/
export class FileSystemService {
private baseDir: string;
private adapter: IFileAdapter;
private nodeId: number;
constructor(nodeId?: number) {
this.nodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
const node = NodeRegistry.getInstance().getNode(this.nodeId);
if (!node || node.type === 'local' || !node.host) {
this.baseDir = process.env.COMPOSE_DIR || '/app/compose';
this.adapter = new LocalFileAdapter();
} else {
this.baseDir = node.compose_dir;
if (!this.baseDir || typeof this.baseDir !== 'string' || this.baseDir.trim() === '') {
throw new Error(`Remote node "${node.name}" has no compose_dir configured. Please set a compose directory in the Node Manager.`);
}
this.adapter = new SSHFileAdapter(node);
}
this.baseDir = NodeRegistry.getInstance().getComposeDir(
nodeId ?? NodeRegistry.getInstance().getDefaultNodeId()
);
}
public static getInstance(nodeId?: number): FileSystemService {
return new FileSystemService(nodeId);
}
/**
* Check if a directory contains a valid compose file
*/
private async hasComposeFile(dir: string): Promise<boolean> {
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) {
try {
await this.adapter.access(path.join(dir, file));
await fsPromises.access(path.join(dir, file));
return true;
} catch {
// Continue checking other options
// continue
}
}
return false;
}
/**
* Get the path to the compose file for a stack
* Throws if no compose file is found
*/
private async getComposeFilePath(stackName: string): Promise<string> {
const stackDir = path.join(this.baseDir, stackName);
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) {
const filePath = path.join(stackDir, file);
try {
await this.adapter.access(filePath);
await fsPromises.access(filePath);
return filePath;
} catch {
// Continue checking other options
// continue
}
}
throw new Error(`No compose file found for stack: ${stackName}`);
}
/**
* Get all stacks (directories containing compose files)
* Returns array of stack names (directory names)
*/
async getStacks(): Promise<string[]> {
try {
const items = await this.adapter.readdir(this.baseDir, { withFileTypes: true });
const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true });
const stackNames: string[] = [];
for (const item of items) {
@@ -83,46 +61,33 @@ export class FileSystemService {
if (!item.name || typeof item.name !== 'string') continue;
const stackDir = path.join(this.baseDir, item.name);
const hasCompose = await this.hasComposeFile(stackDir);
if (hasCompose) {
if (await this.hasComposeFile(stackDir)) {
stackNames.push(item.name);
}
}
return stackNames;
} catch (error: any) {
const nodeName = NodeRegistry.getInstance().getNode(this.nodeId)?.name || 'Unknown';
console.warn(`[SFTP] Failed to fetch stacks for Node ${nodeName}: ${error.message || error}`);
console.warn(`[FileSystemService] Failed to list stacks: ${error.message}`);
return [];
}
}
/**
* Get the content of a stack's compose file
*/
async getStackContent(stackName: string): Promise<string> {
try {
const filePath = await this.getComposeFilePath(stackName);
return await this.adapter.readFile(filePath, 'utf-8');
return await fsPromises.readFile(filePath, 'utf-8');
} catch (error) {
console.error('Error reading stack content:', error);
throw new Error(`Failed to read stack: ${stackName}`);
}
}
/**
* Save content to a stack's compose file
* Always writes to compose.yaml (standardizing on this filename)
*/
async saveStackContent(stackName: string, content: string): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
const filePath = path.join(stackDir, 'compose.yaml');
const filePath = path.join(this.baseDir, stackName, 'compose.yaml');
console.log('Saving to path:', filePath);
try {
await this.adapter.writeFile(filePath, content, 'utf-8');
await fsPromises.writeFile(filePath, content, 'utf-8');
console.log('File written successfully');
} catch (error) {
console.error('Error writing file:', error);
@@ -130,55 +95,42 @@ export class FileSystemService {
}
}
/**
* Check if a stack has an .env file
*/
async envExists(stackName: string): Promise<boolean> {
const envPath = path.join(this.baseDir, stackName, '.env');
try {
await this.adapter.access(envPath);
await fsPromises.access(path.join(this.baseDir, stackName, '.env'));
return true;
} catch {
return false;
}
}
// Proxy to adapter read/write operations for use in other services and generic routes
async readFile(filePath: string, encoding: BufferEncoding = 'utf-8'): Promise<string> {
return this.adapter.readFile(filePath, encoding);
return fsPromises.readFile(filePath, encoding);
}
async writeFile(filePath: string, content: string, encoding: BufferEncoding = 'utf-8'): Promise<void> {
return this.adapter.writeFile(filePath, content, encoding);
return fsPromises.writeFile(filePath, content, encoding);
}
async access(filePath: string): Promise<void> {
return this.adapter.access(filePath);
return fsPromises.access(filePath);
}
/**
* Get the content of a stack's .env file
*/
async getEnvContent(stackName: string): Promise<string> {
const envPath = path.join(this.baseDir, stackName, '.env');
try {
return await this.adapter.readFile(envPath, 'utf-8');
return await fsPromises.readFile(envPath, 'utf-8');
} catch (error) {
console.error('Error reading env file:', error);
throw new Error(`Failed to read env file for stack: ${stackName}`);
}
}
/**
* Save content to a stack's .env file
*/
async saveEnvContent(stackName: string, content: string): Promise<void> {
const envPath = path.join(this.baseDir, stackName, '.env');
console.log('Saving env to path:', envPath);
try {
await this.adapter.writeFile(envPath, content, 'utf-8');
await fsPromises.writeFile(envPath, content, 'utf-8');
console.log('Env file written successfully');
} catch (error) {
console.error('Error writing env file:', error);
@@ -186,33 +138,22 @@ export class FileSystemService {
}
}
/**
* Create a new stack (directory with boilerplate compose.yaml)
*/
async createStack(stackName: string): Promise<void> {
// Validate stack name (no special characters, not empty)
if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) {
throw new Error('Stack name must contain only alphanumeric characters, underscores, or hyphens');
}
const stackDir = path.join(this.baseDir, stackName);
// Check if directory already exists
try {
await this.adapter.access(stackDir);
await fsPromises.access(stackDir);
throw new Error(`Stack "${stackName}" already exists`);
} catch (error: any) {
if (error.message.includes('already exists')) {
throw error;
}
// Directory doesn't exist, proceed
if (error.message.includes('already exists')) throw error;
}
// Create the directory
await this.adapter.mkdir(stackDir, { recursive: true });
await fsPromises.mkdir(stackDir, { recursive: true });
// Write boilerplate compose.yaml
const composePath = path.join(stackDir, 'compose.yaml');
const boilerplate = `services:
app:
image: nginx:latest
@@ -221,7 +162,7 @@ export class FileSystemService {
restart: always
`;
try {
await this.adapter.writeFile(composePath, boilerplate, 'utf-8');
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), boilerplate, 'utf-8');
console.log('Stack created successfully:', stackName);
} catch (error) {
console.error('Error creating stack:', error);
@@ -229,14 +170,10 @@ export class FileSystemService {
}
}
/**
* Delete a stack (entire directory and its contents)
*/
public async deleteStack(stackName: string): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
try {
await this.adapter.rm(stackDir, { recursive: true, force: true });
await fsPromises.rm(stackDir, { recursive: true, force: true });
console.log('Stack deleted successfully:', stackName);
} catch (error: any) {
if (error.code !== 'ENOENT') {
@@ -246,73 +183,58 @@ export class FileSystemService {
}
}
/**
* Get the base directory path for stacks
*/
getBaseDir(): string {
return this.baseDir;
}
/**
* Migrate existing flat-file stacks to directory-based structure
* This runs automatically on server startup
*/
async migrateFlatToDirectory(): Promise<void> {
try {
// Ensure base directory exists
try {
await this.adapter.access(this.baseDir);
await fsPromises.access(this.baseDir);
} catch {
console.log('Creating compose directory:', this.baseDir);
await this.adapter.mkdir(this.baseDir, { recursive: true });
return; // No files to migrate in a new directory
await fsPromises.mkdir(this.baseDir, { recursive: true });
return;
}
const items = await this.adapter.readdir(this.baseDir, { withFileTypes: true });
const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true });
for (const item of items) {
// Only process .yml/.yaml files (skip directories and other files)
if (!item.isFile()) continue;
if (!item.name.endsWith('.yml') && !item.name.endsWith('.yaml')) continue;
const stackName = item.name.replace(/\.(yml|yaml)$/, '');
const stackDir = path.join(this.baseDir, stackName);
// Check if target directory already exists
try {
await this.adapter.access(stackDir);
await fsPromises.access(stackDir);
console.log(`Skipping migration for "${stackName}": directory already exists`);
continue;
} catch {
// Directory doesn't exist, proceed with migration
// Directory doesn't exist, proceed
}
console.log(`Migrating stack: ${stackName}`);
await fsPromises.mkdir(stackDir, { recursive: true });
// Create the stack directory
await this.adapter.mkdir(stackDir, { recursive: true });
// Move compose file to new location (standardize on compose.yaml)
const oldComposePath = path.join(this.baseDir, item.name);
const newComposePath = path.join(stackDir, 'compose.yaml');
await this.adapter.rename(oldComposePath, newComposePath);
await fsPromises.rename(oldComposePath, newComposePath);
// Move env file if it exists (old pattern: stackname.env)
const oldEnvPath = path.join(this.baseDir, `${stackName}.env`);
const newEnvPath = path.join(stackDir, '.env');
try {
await this.adapter.access(oldEnvPath);
await this.adapter.rename(oldEnvPath, newEnvPath);
await fsPromises.access(oldEnvPath);
await fsPromises.rename(oldEnvPath, newEnvPath);
console.log(`Migrated env file for: ${stackName}`);
} catch {
// No env file to migrate, that's fine
// No env file to migrate
}
console.log(`Successfully migrated stack: ${stackName}`);
}
} catch (error) {
console.error('Migration error:', error);
// Don't throw - allow the server to start even if migration fails
}
}
}
}
+82 -42
View File
@@ -1,10 +1,14 @@
import Docker from 'dockerode';
import axios from 'axios';
import { DatabaseService, Node } from './DatabaseService';
/**
* NodeRegistry: Manages Docker daemon connections for multiple nodes.
* Replaces the old singleton DockerController pattern. Each node
* (local or remote) gets its own dedicated Docker client instance.
* NodeRegistry: Manages connections for multiple nodes.
*
* In the Distributed API model:
* - Local nodes: direct Docker socket connection via Dockerode (unchanged)
* - Remote nodes: HTTP/WS proxy to a remote Sencho instance (api_url + api_token)
* No direct Docker TCP connections are made for remote nodes.
*/
export class NodeRegistry {
private static instance: NodeRegistry;
@@ -20,11 +24,10 @@ export class NodeRegistry {
}
/**
* Get a Docker client for a specific node.
* Creates the connection lazily on first request and caches it.
* Get a Docker client for a LOCAL node only.
* Remote nodes are never accessed via Dockerode — use the HTTP proxy instead.
*/
public getDocker(nodeId: number): Docker {
// Return cached connection if available
if (this.connections.has(nodeId)) {
return this.connections.get(nodeId)!;
}
@@ -36,21 +39,27 @@ export class NodeRegistry {
throw new Error(`Node with id ${nodeId} not found`);
}
const docker = this.createDockerClient(node);
if (node.type === 'remote') {
throw new Error(
`Node "${node.name}" is a remote Distributed API node. ` +
`Its Docker daemon is not directly accessible — all requests are proxied via HTTP.`
);
}
const docker = new Docker();
this.connections.set(nodeId, docker);
return docker;
}
/**
* Get the Docker client for the default node.
* This is the backward-compatible path for all existing code.
* Backward-compatible path for local-node code.
*/
public getDefaultDocker(): Docker {
const db = DatabaseService.getInstance();
const defaultNode = db.getDefaultNode();
if (!defaultNode || !defaultNode.id) {
// Absolute fallback: local socket (preserves legacy behavior)
return new Docker();
}
@@ -58,7 +67,7 @@ export class NodeRegistry {
}
/**
* Get the default node ID. Returns the ID of the node marked as default.
* Get the default node ID.
*/
public getDefaultNodeId(): number {
const db = DatabaseService.getInstance();
@@ -75,39 +84,21 @@ export class NodeRegistry {
}
/**
* Create a Docker client based on node configuration.
* - Local nodes: use the default socket (Docker autodetects)
* - Remote nodes: connect via TCP to host:port
* Get the HTTP proxy target for a remote node.
* Returns { apiUrl, apiToken } for use by the HTTP proxy middleware.
*/
private createDockerClient(node: Node): Docker {
if (node.type === 'local') {
// Local node: use the default Docker socket
return new Docker();
public getProxyTarget(nodeId: number): { apiUrl: string; apiToken: string } | null {
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node || node.type !== 'remote' || !node.api_url || !node.api_token) {
return null;
}
// Remote node: connect via Docker TCP API
if (!node.host) {
throw new Error(`Remote node "${node.name}" is missing a host address`);
}
const dockerOptions: Docker.DockerOptions = {
host: node.host,
port: node.port || 2375,
};
// Phase 55.4 — TLS: if all three certs are present, enable secure connection
if (node.tls_ca && node.tls_cert && node.tls_key) {
dockerOptions.ca = node.tls_ca;
dockerOptions.cert = node.tls_cert;
dockerOptions.key = node.tls_key;
}
return new Docker(dockerOptions);
return { apiUrl: node.api_url, apiToken: node.api_token };
}
/**
* Test connectivity to a specific node.
* Returns true if we can ping the Docker daemon.
* - Local: pings the Docker daemon directly
* - Remote: makes a GET to /api/auth/check on the remote Sencho instance
*/
public async testConnection(nodeId: number): Promise<{ success: boolean; error?: string; info?: any }> {
const db = DatabaseService.getInstance();
@@ -117,13 +108,21 @@ export class NodeRegistry {
return { success: false, error: 'Node not found' };
}
if (node.type === 'remote') {
return this.testRemoteConnection(node);
}
return this.testLocalConnection(nodeId);
}
private async testLocalConnection(nodeId: number): Promise<{ success: boolean; error?: string; info?: any }> {
const db = DatabaseService.getInstance();
try {
const docker = this.createDockerClient(node);
const docker = new Docker();
const info = await docker.info();
// Validate the payload contains actual Docker daemon info, not arbitrary HTML
if (!info || !info.OperatingSystem || typeof info.Containers !== 'number') {
throw new Error("Invalid response from Docker API. Did you provide a web port instead of the Docker daemon port?");
throw new Error('Invalid response from Docker daemon.');
}
db.updateNodeStatus(nodeId, 'online');
@@ -147,8 +146,49 @@ export class NodeRegistry {
}
}
private async testRemoteConnection(node: Node): Promise<{ success: boolean; error?: string; info?: any }> {
const db = DatabaseService.getInstance();
if (!node.api_url || !node.api_token) {
return { success: false, error: 'Remote node is missing an API URL or token. Configure it in Settings → Nodes.' };
}
try {
const response = await axios.get(`${node.api_url}/api/auth/check`, {
headers: { Authorization: `Bearer ${node.api_token}` },
timeout: 8000,
});
if (response.status === 200) {
db.updateNodeStatus(node.id, 'online');
return {
success: true,
info: {
name: node.name,
serverVersion: 'Remote Sencho',
os: 'Remote',
architecture: 'Remote',
containers: '—',
containersRunning: '—',
images: '—',
memTotal: 0,
cpus: '—',
}
};
}
throw new Error(`Unexpected status ${response.status}`);
} catch (error: any) {
db.updateNodeStatus(node.id, 'offline');
const msg = error.response?.status === 401
? 'Authentication failed — check the API token.'
: (error.message || 'Connection failed');
return { success: false, error: msg };
}
}
/**
* Evict a cached connection (e.g., after node config changes).
* Evict a cached Docker connection (e.g., after node config change).
*/
public evictConnection(nodeId: number): void {
this.connections.delete(nodeId);
@@ -162,7 +202,7 @@ export class NodeRegistry {
}
/**
* Get the compose directory for a specific node.
* Get the compose directory for a local node.
*/
public getComposeDir(nodeId: number): string {
const db = DatabaseService.getInstance();
-9
View File
@@ -1,9 +0,0 @@
export interface IFileAdapter {
access(filePath: string): Promise<void>;
readdir(dirPath: string, options?: any): Promise<any[]>;
readFile(filePath: string, encoding: string): Promise<string>;
writeFile(filePath: string, content: string, encoding: string): Promise<void>;
mkdir(dirPath: string, options?: any): Promise<void>;
rm(targetPath: string, options?: any): Promise<void>;
rename(oldPath: string, newPath: string): Promise<void>;
}
@@ -1,27 +0,0 @@
import { promises as fs } from 'fs';
import { IFileAdapter } from './IFileAdapter';
export class LocalFileAdapter implements IFileAdapter {
async access(filePath: string): Promise<void> {
return fs.access(filePath);
}
async readdir(dirPath: string, options?: any): Promise<any[]> {
return fs.readdir(dirPath, options);
}
async readFile(filePath: string, encoding: any): Promise<string> {
const raw = await fs.readFile(filePath, encoding);
return typeof raw === 'string' ? raw : raw.toString(encoding || 'utf-8');
}
async writeFile(filePath: string, content: string, encoding: any): Promise<void> {
await fs.writeFile(filePath, content, encoding);
}
async mkdir(dirPath: string, options?: any): Promise<void> {
await fs.mkdir(dirPath, options);
}
async rm(targetPath: string, options?: any): Promise<void> {
return fs.rm(targetPath, options);
}
async rename(oldPath: string, newPath: string): Promise<void> {
return fs.rename(oldPath, newPath);
}
}
-125
View File
@@ -1,125 +0,0 @@
import Client from 'ssh2-sftp-client';
import { IFileAdapter } from './IFileAdapter';
import { Node } from '../DatabaseService';
export class SSHFileAdapter implements IFileAdapter {
private node: Node;
constructor(node: Node) {
this.node = node;
}
private async getClient() {
const sftp = new Client();
await sftp.connect({
host: this.node.host,
port: this.node.ssh_port || 22,
username: this.node.ssh_user!,
password: this.node.ssh_password,
privateKey: this.node.ssh_key,
readyTimeout: 10000,
});
return sftp;
}
async access(filePath: string): Promise<void> {
const sftp = await this.getClient();
try {
const exists = await sftp.exists(filePath);
if (!exists) throw Object.assign(new Error(), { code: 'ENOENT' });
} finally {
await sftp.end();
}
}
async readdir(dirPath: string, options?: any): Promise<any[]> {
const sftp = await this.getClient();
try {
const list = await sftp.list(dirPath);
// Guard: filter out any entries with missing or non-string names to prevent
// downstream path.join crashes (TypeError on undefined.split)
const valid = list.filter((item: any) => item.name && typeof item.name === 'string');
if (options?.withFileTypes) {
return valid.map((item: any) => ({
name: item.name,
isDirectory: () => item.type === 'd',
isFile: () => item.type === '-',
}));
}
return valid.map((item: any) => item.name);
} catch(err: any) {
if(err.code === 2 || err.message.includes('No such file')) throw Object.assign(new Error(), { code: 'ENOENT' });
throw err;
} finally {
await sftp.end();
}
}
async readFile(filePath: string, encoding: any): Promise<string> {
const sftp = await this.getClient();
try {
const buffer = await sftp.get(filePath);
if (Buffer.isBuffer(buffer)) {
return buffer.toString(encoding as BufferEncoding);
}
return buffer as unknown as string;
} catch(err: any) {
if(err.code === 2 || err.message.includes('No such file')) throw Object.assign(new Error(), { code: 'ENOENT' });
throw err;
} finally {
await sftp.end();
}
}
async writeFile(filePath: string, content: string, encoding: any): Promise<void> {
const sftp = await this.getClient();
try {
await sftp.put(Buffer.from(content, encoding as BufferEncoding), filePath);
} finally {
await sftp.end();
}
}
async mkdir(dirPath: string, options?: any): Promise<void> {
const sftp = await this.getClient();
try {
const exists = await sftp.exists(dirPath);
if (!exists) {
await sftp.mkdir(dirPath, options?.recursive);
}
} finally {
await sftp.end();
}
}
async rm(targetPath: string, options?: any): Promise<void> {
const sftp = await this.getClient();
try {
const type = await sftp.exists(targetPath);
if (type === 'd') {
await sftp.rmdir(targetPath, options?.recursive);
} else if (type === '-') {
await sftp.delete(targetPath);
} else if (!options?.force) {
throw Object.assign(new Error(), { code: 'ENOENT' });
}
} catch(err: any) {
if(err.code === 2 || err.message.includes('No such file')) {
if (options?.force) return;
throw Object.assign(new Error(), { code: 'ENOENT' });
}
throw err;
} finally {
await sftp.end();
}
}
async rename(oldPath: string, newPath: string): Promise<void> {
const sftp = await this.getClient();
try {
await sftp.rename(oldPath, newPath);
} finally {
await sftp.end();
}
}
}