#!/usr/bin/env node import { readFileSync, existsSync } from 'node:fs' import { join } from 'node:path' import { homedir, platform } from 'node:os' import { Command } from 'commander' import { ConfigSchema, type Config } from '../src/types.js' import { BaseCache } from '../src/adapters/cache/base.js' import { MemoryCache } from '../src/adapters/cache/memory.js' import { SqliteCache } from '../src/adapters/cache/sqlite.js' import { BaseClient } from '../src/adapters/client/base.js' import { WebDAVClientAdapter } from '../src/adapters/client/webdav.js' import { FTPClientAdapter } from '../src/adapters/client/ftp.js' import { SFTPClientAdapter } from '../src/adapters/client/sftp.js' import { SFTPServer } from '../src/adapters/server/sftp.js' import { FTPServer } from '../src/adapters/server/ftp.js' import { WebDAVServer } from '../src/adapters/server/webdav.js' import { NFSServer } from '../src/adapters/server/nfs.js' import { SMBServer } from '../src/adapters/server/smb.js' import type { BaseServer } from '../src/adapters/server/base.js' import { logger, attachSqliteTransport } from '../src/logger.js' import * as launchd from '../src/service/launchd.js' import * as systemd from '../src/service/systemd.js' const CONFIG_PATH = join(homedir(), '.config', 'sftp-proxy', 'config.json') /** * Loads and validates the config file from ~/.config/sftp-proxy/config.json. * Exits with a clear error message if the file is missing or invalid. */ function loadConfig(): Config { if (!existsSync(CONFIG_PATH)) { console.error(`Config file not found: ${CONFIG_PATH}`) console.error('Create one with your client configuration. See documentation for schema.') process.exit(1) } let raw: unknown try { raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')) } catch (err) { console.error(`Failed to parse config file: ${(err as Error).message}`) process.exit(1) } const result = ConfigSchema.safeParse(raw) if (!result.success) { console.error('Invalid config:') for (const issue of result.error.issues) { console.error(` ${issue.path.join('.')}: ${issue.message}`) } process.exit(1) } return result.data } /** * Creates a cache instance based on the client's cache config. * If using SQLite, also attaches the winston transport. */ function createCache(type: 'sqlite' | 'memory'): BaseCache { if (type === 'sqlite') { const cache = new SqliteCache() attachSqliteTransport(cache) return cache } return new MemoryCache() } /** * Creates a client adapter based on the client type config. */ function createClient( clientConfig: Config['clients'][number], cache: BaseCache, ): BaseClient { const baseConfig = { url: clientConfig.url, basePath: clientConfig.basePath, username: clientConfig.username, password: clientConfig.password, mountPath: clientConfig.mountPath, concurrency: clientConfig.concurrency, } switch (clientConfig.type) { case 'webdav': return new WebDAVClientAdapter(baseConfig, cache) case 'ftp': return new FTPClientAdapter(baseConfig, cache) case 'sftp': return new SFTPClientAdapter(baseConfig, cache) default: throw new Error(`Unknown client type: ${clientConfig.type}`) } } /** * Detects whether we're on macOS (launchd) or Linux (systemd). */ function getServiceManager() { return platform() === 'darwin' ? launchd : systemd } const program = new Command() program .name('sftp-proxy') .description('Local protocol servers that proxy to remote storage') .version('1.0.0') program .command('start') .description('Start all configured servers in the foreground') .action(async () => { const config = loadConfig() // Build clients const clients: BaseClient[] = config.clients.map(clientConf => { const cache = createCache(clientConf.cache.type) return createClient(clientConf, cache) }) // Start sync workers for all clients for (const client of clients) { client.sync.start() logger.info(`Sync started for ${client.mountPath}`) } // Track all started servers for graceful shutdown const servers: BaseServer[] = [] // Start SFTP server (always on — it's the primary protocol) const sftpPort = config.servers?.sftp?.port ?? config.port const sftpServer = new SFTPServer(clients, sftpPort, config.credentials) await sftpServer.start() servers.push(sftpServer) // Start WebDAV server if configured if (config.servers?.webdav) { logger.info(`Starting WebDAV server on port ${config.servers.webdav.port}`) const webdavServer = new WebDAVServer(clients, config.servers.webdav.port, config.credentials) await webdavServer.start() servers.push(webdavServer) } // Start FTP server if configured if (config.servers?.ftp) { const ftpConf = config.servers.ftp const ftpServer = new FTPServer(clients, { port: ftpConf.port, pasv_url: ftpConf.pasv_url, pasv_min: ftpConf.pasv_min, pasv_max: ftpConf.pasv_max, }, config.credentials) await ftpServer.start() servers.push(ftpServer) } // Warn about unimplemented servers if (config.servers?.nfs) { logger.warn('NFS server is configured but not yet implemented. Skipping.') } if (config.servers?.smb) { logger.warn('SMB server is configured but not yet implemented. Skipping.') } // Graceful shutdown const shutdown = async () => { logger.info('Shutting down...') for (const client of clients) { client.sync.stop() } for (const server of servers) { await server.stop() } process.exit(0) } process.on('SIGINT', shutdown) process.on('SIGTERM', shutdown) }) program .command('install') .description('Install as background service (auto-detect launchd vs systemd)') .action(() => { const svc = getServiceManager() svc.install() }) program .command('uninstall') .description('Remove background service') .action(() => { const svc = getServiceManager() svc.uninstall() }) program .command('status') .description('Show service status') .action(() => { const svc = getServiceManager() console.log(svc.status()) }) program .command('sync') .description('Force re-index all clients') .action(async () => { const config = loadConfig() const clients: BaseClient[] = config.clients.map(clientConf => { const cache = createCache(clientConf.cache.type) return createClient(clientConf, cache) }) logger.info('Force syncing all clients...') const syncPromises = clients.map(async (client) => { client.sync.start() await client.sync.forceSync('/') client.sync.stop() logger.info(`Sync complete for ${client.mountPath}`) }) await Promise.all(syncPromises) logger.info('All clients synced') process.exit(0) }) program .command('logs') .description('Print last 500 log entries') .action(() => { const cache = new SqliteCache() const entries = cache.readLogs(500) // Print in chronological order (readLogs returns newest first) for (const entry of entries.reverse()) { const ts = new Date(entry.timestamp).toISOString() console.log(`${ts} [${entry.level}] ${entry.message}`) } cache.close() }) program.parse()