From aef20b9d354b7e4b1b96066e5a4a45c1d26c3cfb Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Fri, 20 Feb 2026 17:14:35 +1100 Subject: [PATCH] initial commit --- .gitignore | 80 + bin/sftp-proxy.ts | 250 ++ package-lock.json | 3168 +++++++++++++++++++ package.json | 39 + src/__tests__/e2e-ftp.test.ts | 247 ++ src/__tests__/e2e-webdav.test.ts | 329 ++ src/__tests__/e2e.test.ts | 405 +++ src/__tests__/helpers.ts | 110 + src/__tests__/sync.test.ts | 198 ++ src/__tests__/types.test.ts | 103 + src/adapters/cache/__tests__/memory.test.ts | 109 + src/adapters/cache/base.ts | 32 + src/adapters/cache/memory.ts | 53 + src/adapters/cache/sqlite.ts | 142 + src/adapters/client/base.ts | 62 + src/adapters/client/ftp.ts | 126 + src/adapters/client/sftp.ts | 198 ++ src/adapters/client/webdav.ts | 127 + src/adapters/server/__tests__/base.test.ts | 205 ++ src/adapters/server/base.ts | 196 ++ src/adapters/server/ftp.ts | 301 ++ src/adapters/server/nfs.ts | 48 + src/adapters/server/sftp.ts | 457 +++ src/adapters/server/smb.ts | 54 + src/adapters/server/webdav.ts | 497 +++ src/logger.ts | 56 + src/service/launchd.ts | 121 + src/service/systemd.ts | 101 + src/sync.ts | 270 ++ src/types.ts | 101 + tsconfig.json | 19 + vitest.config.ts | 8 + 32 files changed, 8212 insertions(+) create mode 100644 .gitignore create mode 100644 bin/sftp-proxy.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/__tests__/e2e-ftp.test.ts create mode 100644 src/__tests__/e2e-webdav.test.ts create mode 100644 src/__tests__/e2e.test.ts create mode 100644 src/__tests__/helpers.ts create mode 100644 src/__tests__/sync.test.ts create mode 100644 src/__tests__/types.test.ts create mode 100644 src/adapters/cache/__tests__/memory.test.ts create mode 100644 src/adapters/cache/base.ts create mode 100644 src/adapters/cache/memory.ts create mode 100644 src/adapters/cache/sqlite.ts create mode 100644 src/adapters/client/base.ts create mode 100644 src/adapters/client/ftp.ts create mode 100644 src/adapters/client/sftp.ts create mode 100644 src/adapters/client/webdav.ts create mode 100644 src/adapters/server/__tests__/base.test.ts create mode 100644 src/adapters/server/base.ts create mode 100644 src/adapters/server/ftp.ts create mode 100644 src/adapters/server/nfs.ts create mode 100644 src/adapters/server/sftp.ts create mode 100644 src/adapters/server/smb.ts create mode 100644 src/adapters/server/webdav.ts create mode 100644 src/logger.ts create mode 100644 src/service/launchd.ts create mode 100644 src/service/systemd.ts create mode 100644 src/sync.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db63d27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,80 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ + +# nyc test coverage +.nyc_output + +# Dependency directories +node_modules/ +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env.production +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# Build output +dist/ +build/ diff --git a/bin/sftp-proxy.ts b/bin/sftp-proxy.ts new file mode 100644 index 0000000..815ad96 --- /dev/null +++ b/bin/sftp-proxy.ts @@ -0,0 +1,250 @@ +#!/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() diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1b0b0e4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3168 @@ +{ + "name": "sftp-proxy", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sftp-proxy", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "basic-ftp": "^5.1.0", + "better-sqlite3": "^12.6.2", + "commander": "^14.0.3", + "ftp-srv": "^4.6.3", + "ssh2": "^1.17.0", + "webdav": "^5.9.0", + "winston": "^3.19.0", + "zod": "^4.3.6" + }, + "bin": { + "sftp-proxy": "dist/bin/sftp-proxy.js" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^25.3.0", + "@types/ssh2": "^1.15.5", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } + }, + "node_modules/@buttercup/fetch": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@buttercup/fetch/-/fetch-0.2.1.tgz", + "integrity": "sha512-sCgECOx8wiqY8NN1xN22BqqKzXYIG2AicNLlakOAI4f0WgyLVUbAigMf8CZhBtJxdudTcB1gD5lciqi44jwJvg==", + "license": "MIT", + "optionalDependencies": { + "node-fetch": "^3.3.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz", + "integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansi-styles/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/ansi-styles/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base-64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-1.0.0.tgz", + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/basic-ftp": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", + "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/better-sqlite3": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bunyan": { + "version": "1.8.15", + "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.15.tgz", + "integrity": "sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig==", + "engines": [ + "node >=0.10.0" + ], + "license": "MIT", + "bin": { + "bunyan": "bin/bunyan" + }, + "optionalDependencies": { + "dtrace-provider": "~0.8", + "moment": "^2.19.3", + "mv": "~2", + "safe-json-stringify": "~1" + } + }, + "node_modules/byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/byte-length/-/byte-length-1.0.2.tgz", + "integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==", + "license": "MIT" + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT", + "optional": true + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dtrace-provider": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz", + "integrity": "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==", + "hasInstallScript": true, + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "nan": "^2.14.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz", + "integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/ftp-srv": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/ftp-srv/-/ftp-srv-4.6.3.tgz", + "integrity": "sha512-k9q06Ab04CwY7n9HL5H5Zn+/ccohUUH8K2UjQ6dnAlE3NIFdqFI8Paanq+WFbfawZXeK29vP0zgZWfr28N33dw==", + "license": "MIT", + "dependencies": { + "bluebird": "^3.5.1", + "bunyan": "^1.8.12", + "ip": "^1.1.5", + "lodash": "^4.17.15", + "moment": "^2.22.1", + "uuid": "^3.2.1", + "yargs": "^15.4.1" + }, + "bin": { + "ftp-srv": "bin/index.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", + "integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "optional": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "optional": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/hot-patcher": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-2.0.1.tgz", + "integrity": "sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz", + "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==", + "license": "MIT" + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/layerr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/layerr/-/layerr-3.0.0.tgz", + "integrity": "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "optional": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mv": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", + "integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "~0.5.1", + "ncp": "~2.0.0", + "rimraf": "~2.4.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/nan": { + "version": "2.25.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.25.0.tgz", + "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "license": "MIT", + "optional": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/ncp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", + "integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==", + "license": "MIT", + "optional": true, + "bin": { + "ncp": "bin/ncp" + } + }, + "node_modules/nested-property": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nested-property/-/nested-property-4.0.0.tgz", + "integrity": "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-posix": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz", + "integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==", + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rimraf": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", + "integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^6.0.1" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-json-stringify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz", + "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", + "license": "MIT", + "optional": true + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webdav": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/webdav/-/webdav-5.9.0.tgz", + "integrity": "sha512-OMJ6wtK1WvCO++aOLoQgE96S8KT4e5aaClWHmHXfFU369r4eyELN569B7EqT4OOUb99mmO58GkyuiCv/Ag6J0Q==", + "license": "MIT", + "dependencies": { + "@buttercup/fetch": "^0.2.1", + "base-64": "^1.0.0", + "byte-length": "^1.0.2", + "entities": "^6.0.1", + "fast-xml-parser": "^5.3.4", + "hot-patcher": "^2.0.1", + "layerr": "^3.0.0", + "md5": "^2.3.0", + "minimatch": "^9.0.5", + "nested-property": "^4.0.0", + "node-fetch": "^3.3.2", + "path-posix": "^1.0.0", + "url-join": "^5.0.0", + "url-parse": "^1.5.10" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..281ac37 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "sftp-proxy", + "version": "1.0.0", + "description": "Local protocol servers (SFTP, FTP, WebDAV) that proxy to remote storage via a generic client layer", + "main": "dist/bin/sftp-proxy.js", + "bin": { + "sftp-proxy": "dist/bin/sftp-proxy.js" + }, + "scripts": { + "build": "tsc", + "start": "node dist/bin/sftp-proxy.js start", + "dev": "tsx bin/sftp-proxy.ts start", + "sync": "node dist/bin/sftp-proxy.js sync", + "test": "vitest run", + "test:watch": "vitest" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "dependencies": { + "basic-ftp": "^5.1.0", + "better-sqlite3": "^12.6.2", + "commander": "^14.0.3", + "ftp-srv": "^4.6.3", + "ssh2": "^1.17.0", + "webdav": "^5.9.0", + "winston": "^3.19.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^25.3.0", + "@types/ssh2": "^1.15.5", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } +} diff --git a/src/__tests__/e2e-ftp.test.ts b/src/__tests__/e2e-ftp.test.ts new file mode 100644 index 0000000..25c2444 --- /dev/null +++ b/src/__tests__/e2e-ftp.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' +import * as ftp from 'basic-ftp' +import { FTPServer } from '../adapters/server/ftp.js' +import type { BaseClient } from '../adapters/client/base.js' +import { + createMockClient, populateCache, + HOME_TREE, HOME_FILES, STORAGE_TREE, STORAGE_FILES, +} from './helpers.js' + +vi.mock('../logger.js', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})) + +const TEST_PORT = 2391 +const TEST_USER = 'testuser' +const TEST_PASS = 'testpass' + +/** Creates a connected + authenticated FTP client. */ +async function connectFTP(): Promise { + const client = new ftp.Client() + await client.access({ + host: '127.0.0.1', + port: TEST_PORT, + user: TEST_USER, + password: TEST_PASS, + secure: false, + }) + return client +} + +describe('E2E: FTP Server', () => { + let server: FTPServer + let homeClient: BaseClient + let storageClient: BaseClient + + beforeAll(async () => { + homeClient = createMockClient('/home', HOME_TREE, HOME_FILES) + storageClient = createMockClient('/storage', STORAGE_TREE, STORAGE_FILES) + + await populateCache(homeClient, HOME_TREE) + await populateCache(storageClient, STORAGE_TREE) + + server = new FTPServer( + [homeClient, storageClient], + { port: TEST_PORT, pasv_url: '127.0.0.1', pasv_min: 3000, pasv_max: 3100 }, + { username: TEST_USER, password: TEST_PASS }, + ) + await server.start() + }) + + afterAll(async () => { + await server.stop() + }) + + // --------------------------------------------------------------------------- + // Authentication + // --------------------------------------------------------------------------- + + it('should reject invalid credentials', async () => { + const client = new ftp.Client() + await expect( + client.access({ + host: '127.0.0.1', + port: TEST_PORT, + user: 'wrong', + password: 'wrong', + secure: false, + }) + ).rejects.toThrow() + client.close() + }) + + // --------------------------------------------------------------------------- + // Directory listing + // --------------------------------------------------------------------------- + + it('should list the virtual root', async () => { + const client = await connectFTP() + try { + const list = await client.list('/') + const names = list.map(e => e.name) + expect(names).toContain('home') + expect(names).toContain('storage') + } finally { + client.close() + } + }) + + it('should list mount point contents', async () => { + const client = await connectFTP() + try { + const list = await client.list('/home') + const names = list.map(e => e.name) + expect(names).toContain('docs') + expect(names).toContain('readme.txt') + } finally { + client.close() + } + }) + + it('should list nested directory contents', async () => { + const client = await connectFTP() + try { + const list = await client.list('/home/docs') + const names = list.map(e => e.name) + expect(names).toContain('hello.txt') + expect(names).toContain('world.txt') + } finally { + client.close() + } + }) + + it('should list storage mount contents', async () => { + const client = await connectFTP() + try { + const list = await client.list('/storage/backups') + const names = list.map(e => e.name) + expect(names).toContain('db.sql') + } finally { + client.close() + } + }) + + // --------------------------------------------------------------------------- + // Directory navigation + // --------------------------------------------------------------------------- + + it('should change directory to mount point', async () => { + const client = await connectFTP() + try { + await client.cd('/home') + const pwd = await client.pwd() + expect(pwd).toBe('/home') + } finally { + client.close() + } + }) + + it('should change directory to nested path', async () => { + const client = await connectFTP() + try { + await client.cd('/home/docs') + const pwd = await client.pwd() + expect(pwd).toBe('/home/docs') + } finally { + client.close() + } + }) + + // --------------------------------------------------------------------------- + // File download + // --------------------------------------------------------------------------- + + it('should download a file', async () => { + const client = await connectFTP() + try { + const chunks: Buffer[] = [] + const writable = new (await import('node:stream')).PassThrough() + writable.on('data', (chunk: Buffer) => chunks.push(chunk)) + + await client.downloadTo(writable, '/home/readme.txt') + const content = Buffer.concat(chunks).toString() + expect(content).toBe('Hello from home!') + } finally { + client.close() + } + }) + + it('should download a nested file', async () => { + const client = await connectFTP() + try { + const chunks: Buffer[] = [] + const writable = new (await import('node:stream')).PassThrough() + writable.on('data', (chunk: Buffer) => chunks.push(chunk)) + + await client.downloadTo(writable, '/home/docs/hello.txt') + const content = Buffer.concat(chunks).toString() + expect(content).toBe('Hello, World!') + } finally { + client.close() + } + }) + + // --------------------------------------------------------------------------- + // File upload + // --------------------------------------------------------------------------- + + it('should upload a file', async () => { + const client = await connectFTP() + try { + const { Readable } = await import('node:stream') + const readable = Readable.from(Buffer.from('Uploaded content')) + + await client.uploadFrom(readable, '/home/uploaded.txt') + expect(homeClient.write).toHaveBeenCalled() + } finally { + client.close() + } + }) + + // --------------------------------------------------------------------------- + // Directory creation + // --------------------------------------------------------------------------- + + it('should create a directory', async () => { + const client = await connectFTP() + try { + // Use raw MKD command — ensureDir does CWD+MKD which fails on virtual paths + await client.send('MKD /home/new-dir') + expect(homeClient.mkdir).toHaveBeenCalledWith('/new-dir') + } finally { + client.close() + } + }) + + // --------------------------------------------------------------------------- + // Delete + // --------------------------------------------------------------------------- + + it('should delete a file', async () => { + const client = await connectFTP() + try { + await client.remove('/home/readme.txt') + expect(homeClient.delete).toHaveBeenCalledWith('/readme.txt') + } finally { + client.close() + } + }) + + // --------------------------------------------------------------------------- + // Rename + // --------------------------------------------------------------------------- + + it('should rename a file', async () => { + const client = await connectFTP() + try { + await client.rename('/home/readme.txt', '/home/renamed.txt') + expect(homeClient.rename).toHaveBeenCalledWith('/readme.txt', '/renamed.txt') + } finally { + client.close() + } + }) +}) diff --git a/src/__tests__/e2e-webdav.test.ts b/src/__tests__/e2e-webdav.test.ts new file mode 100644 index 0000000..741675a --- /dev/null +++ b/src/__tests__/e2e-webdav.test.ts @@ -0,0 +1,329 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' +import http from 'node:http' +import { WebDAVServer } from '../adapters/server/webdav.js' +import type { BaseClient } from '../adapters/client/base.js' +import { + createMockClient, populateCache, + HOME_TREE, HOME_FILES, STORAGE_TREE, STORAGE_FILES, +} from './helpers.js' + +vi.mock('../logger.js', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})) + +const TEST_PORT = 2390 +const TEST_USER = 'testuser' +const TEST_PASS = 'testpass' +const AUTH_HEADER = 'Basic ' + Buffer.from(`${TEST_USER}:${TEST_PASS}`).toString('base64') +const BAD_AUTH = 'Basic ' + Buffer.from('wrong:wrong').toString('base64') + +/** Helper: makes an HTTP request and returns status, headers, and body. */ +function request(opts: { + method: string + path: string + headers?: Record + body?: string +}): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> { + return new Promise((resolve, reject) => { + const req = http.request({ + hostname: '127.0.0.1', + port: TEST_PORT, + method: opts.method, + path: opts.path, + headers: { + Authorization: AUTH_HEADER, + ...opts.headers, + }, + }, (res) => { + const chunks: Buffer[] = [] + res.on('data', (chunk: Buffer) => chunks.push(chunk)) + res.on('end', () => { + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString(), + }) + }) + }) + + req.on('error', reject) + if (opts.body) req.write(opts.body) + req.end() + }) +} + +describe('E2E: WebDAV Server', () => { + let server: WebDAVServer + let homeClient: BaseClient + let storageClient: BaseClient + + beforeAll(async () => { + homeClient = createMockClient('/home', HOME_TREE, HOME_FILES) + storageClient = createMockClient('/storage', STORAGE_TREE, STORAGE_FILES) + + await populateCache(homeClient, HOME_TREE) + await populateCache(storageClient, STORAGE_TREE) + + server = new WebDAVServer( + [homeClient, storageClient], + TEST_PORT, + { username: TEST_USER, password: TEST_PASS }, + ) + await server.start() + }) + + afterAll(async () => { + await server.stop() + }) + + // --------------------------------------------------------------------------- + // Authentication + // --------------------------------------------------------------------------- + + it('should reject requests without auth', async () => { + const res = await new Promise<{ status: number }>((resolve, reject) => { + const req = http.request({ + hostname: '127.0.0.1', + port: TEST_PORT, + method: 'PROPFIND', + path: '/', + }, (res) => { + res.resume() + res.on('end', () => resolve({ status: res.statusCode ?? 0 })) + }) + req.on('error', reject) + req.end() + }) + expect(res.status).toBe(401) + }) + + it('should reject invalid credentials', async () => { + const res = await request({ + method: 'PROPFIND', + path: '/', + headers: { Authorization: BAD_AUTH }, + }) + expect(res.status).toBe(401) + }) + + // --------------------------------------------------------------------------- + // OPTIONS + // --------------------------------------------------------------------------- + + it('should return allowed methods on OPTIONS', async () => { + const res = await request({ method: 'OPTIONS', path: '/' }) + expect(res.status).toBe(200) + expect(res.headers['allow']).toContain('PROPFIND') + expect(res.headers['allow']).toContain('GET') + expect(res.headers['allow']).toContain('PUT') + expect(res.headers['dav']).toContain('1') + }) + + // --------------------------------------------------------------------------- + // PROPFIND + // --------------------------------------------------------------------------- + + it('should PROPFIND root at Depth: 0', async () => { + const res = await request({ + method: 'PROPFIND', + path: '/', + headers: { Depth: '0' }, + }) + expect(res.status).toBe(207) + expect(res.body).toContain('/') + expect(res.body).toContain('') + // Should NOT include children at Depth: 0 + expect(res.body).not.toContain('home') + }) + + it('should PROPFIND root at Depth: 1 showing mount points', async () => { + const res = await request({ + method: 'PROPFIND', + path: '/', + headers: { Depth: '1' }, + }) + expect(res.status).toBe(207) + expect(res.body).toContain('/home/') + expect(res.body).toContain('/storage/') + }) + + it('should PROPFIND /home at Depth: 1 showing contents', async () => { + const res = await request({ + method: 'PROPFIND', + path: '/home', + headers: { Depth: '1' }, + }) + expect(res.status).toBe(207) + expect(res.body).toContain('docs') + expect(res.body).toContain('readme.txt') + }) + + it('should PROPFIND nested directory', async () => { + const res = await request({ + method: 'PROPFIND', + path: '/home/docs', + headers: { Depth: '1' }, + }) + expect(res.status).toBe(207) + expect(res.body).toContain('hello.txt') + expect(res.body).toContain('world.txt') + }) + + it('should reject Depth: infinity', async () => { + const res = await request({ + method: 'PROPFIND', + path: '/', + headers: { Depth: 'infinity' }, + }) + expect(res.status).toBe(403) + }) + + it('should return 404 for nonexistent path', async () => { + const res = await request({ + method: 'PROPFIND', + path: '/nonexistent', + headers: { Depth: '0' }, + }) + expect(res.status).toBe(404) + }) + + // --------------------------------------------------------------------------- + // GET / HEAD + // --------------------------------------------------------------------------- + + it('should GET a file', async () => { + const res = await request({ method: 'GET', path: '/home/readme.txt' }) + expect(res.status).toBe(200) + expect(res.body).toBe('Hello from home!') + }) + + it('should GET a nested file', async () => { + const res = await request({ method: 'GET', path: '/home/docs/hello.txt' }) + expect(res.status).toBe(200) + expect(res.body).toBe('Hello, World!') + }) + + it('should GET a file from storage mount', async () => { + const res = await request({ method: 'GET', path: '/storage/backups/db.sql' }) + expect(res.status).toBe(200) + expect(res.body).toBe('CREATE TABLE test;') + }) + + it('should return 404 for GET on nonexistent file', async () => { + const res = await request({ method: 'GET', path: '/home/nonexistent.txt' }) + expect(res.status).toBe(404) + }) + + it('should HEAD a file', async () => { + const res = await request({ method: 'HEAD', path: '/home/readme.txt' }) + expect(res.status).toBe(200) + expect(res.headers['content-length']).toBe('42') + expect(res.body).toBe('') + }) + + // --------------------------------------------------------------------------- + // PUT + // --------------------------------------------------------------------------- + + it('should PUT a file', async () => { + const res = await request({ + method: 'PUT', + path: '/home/new-file.txt', + body: 'New content!', + }) + expect(res.status).toBe(201) + expect(homeClient.write).toHaveBeenCalled() + }) + + it('should reject PUT to virtual root', async () => { + const res = await request({ + method: 'PUT', + path: '/new-file.txt', + body: 'content', + }) + expect(res.status).toBe(403) + }) + + // --------------------------------------------------------------------------- + // DELETE + // --------------------------------------------------------------------------- + + it('should DELETE a file', async () => { + const res = await request({ method: 'DELETE', path: '/home/readme.txt' }) + expect(res.status).toBe(204) + expect(homeClient.delete).toHaveBeenCalledWith('/readme.txt') + }) + + // --------------------------------------------------------------------------- + // MKCOL + // --------------------------------------------------------------------------- + + it('should MKCOL to create a directory', async () => { + const res = await request({ method: 'MKCOL', path: '/home/new-dir' }) + expect(res.status).toBe(201) + expect(homeClient.mkdir).toHaveBeenCalledWith('/new-dir') + }) + + // --------------------------------------------------------------------------- + // MOVE + // --------------------------------------------------------------------------- + + it('should MOVE (rename) a file', async () => { + const res = await request({ + method: 'MOVE', + path: '/home/readme.txt', + headers: { Destination: 'http://127.0.0.1:2390/home/renamed.txt' }, + }) + expect(res.status).toBe(201) + expect(homeClient.rename).toHaveBeenCalledWith('/readme.txt', '/renamed.txt') + }) + + it('should reject MOVE without Destination header', async () => { + const res = await request({ method: 'MOVE', path: '/home/readme.txt' }) + expect(res.status).toBe(400) + }) + + it('should reject MOVE across mount points', async () => { + const res = await request({ + method: 'MOVE', + path: '/home/readme.txt', + headers: { Destination: 'http://127.0.0.1:2390/storage/readme.txt' }, + }) + expect(res.status).toBe(403) + }) + + // --------------------------------------------------------------------------- + // LOCK / UNLOCK + // --------------------------------------------------------------------------- + + it('should handle LOCK with a fake token', async () => { + const res = await request({ method: 'LOCK', path: '/home/readme.txt' }) + expect(res.status).toBe(200) + expect(res.headers['lock-token']).toBeTruthy() + expect(res.body).toContain('opaquelocktoken:') + expect(res.body).toContain('') + }) + + it('should handle UNLOCK', async () => { + const res = await request({ + method: 'UNLOCK', + path: '/home/readme.txt', + headers: { 'Lock-Token': '' }, + }) + expect(res.status).toBe(204) + }) + + // --------------------------------------------------------------------------- + // Unsupported methods + // --------------------------------------------------------------------------- + + it('should return 405 for unsupported methods', async () => { + const res = await request({ method: 'PATCH', path: '/' }) + expect(res.status).toBe(405) + }) +}) diff --git a/src/__tests__/e2e.test.ts b/src/__tests__/e2e.test.ts new file mode 100644 index 0000000..e3c1b0e --- /dev/null +++ b/src/__tests__/e2e.test.ts @@ -0,0 +1,405 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' +import { Readable } from 'node:stream' +import { posix, join } from 'node:path' +import { mkdirSync, rmSync } from 'node:fs' +import ssh2 from 'ssh2' +const { Client: SSHClient } = ssh2 +import { MemoryCache } from '../adapters/cache/memory.js' +import { SFTPServer } from '../adapters/server/sftp.js' +import type { BaseClient } from '../adapters/client/base.js' +import type { GenericNode } from '../types.js' +import { SyncWorker } from '../sync.js' + +/** Suppress logger output during tests. */ +vi.mock('../logger.js', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})) + +const TEST_PORT = 2299 +const TEST_USER = 'testuser' +const TEST_PASS = 'testpass' +const TEST_TEMP_DIR = join(process.cwd(), '.test-temp') +const TEST_HOST_KEY = join(TEST_TEMP_DIR, 'host.key') + +function makeNode(path: string, isDir = false, size = 1024): GenericNode { + const parts = path.split('/') + return { + path, + name: parts[parts.length - 1] || '/', + isDir, + size: isDir ? 0 : size, + modified: new Date('2025-06-01T12:00:00Z'), + etag: undefined, + } +} + +/** + * Creates a mock client with an in-memory filesystem. + * The filesystem is backed by a simple map of path → content for files + * and the cache for metadata. + */ +function createMockClient(mountPath: string, tree: Record, fileContents: Record = {}): BaseClient { + const cache = new MemoryCache() + + const mockClient = { + mountPath, + cache, + sync: { + start: vi.fn(), + stop: vi.fn(), + forceSync: vi.fn().mockResolvedValue(undefined), + prioritise: vi.fn(), + waitForDrain: vi.fn().mockResolvedValue(undefined), + } as unknown as SyncWorker, + + list: vi.fn(async (path: string) => { + return tree[path] ?? [] + }), + + stat: vi.fn(async (path: string) => { + // Search all tree entries for the matching node + for (const nodes of Object.values(tree)) { + const found = nodes.find(n => n.path === path) + if (found) return found + } + throw new Error(`Not found: ${path}`) + }), + + read: vi.fn(async (path: string) => { + const content = fileContents[path] ?? `content of ${path}` + return Readable.from(Buffer.from(content)) + }), + + write: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), + delete: vi.fn(async () => {}), + rename: vi.fn(async () => {}), + } + + return mockClient as unknown as BaseClient +} + +/** Connects an ssh2 client and returns the SFTP wrapper. */ +function connectSFTP(): Promise<{ client: SSHClient; sftp: any }> { + return new Promise((resolve, reject) => { + const client = new SSHClient() + + client.on('ready', () => { + client.sftp((err: Error | undefined, sftp: any) => { + if (err) return reject(err) + resolve({ client, sftp }) + }) + }) + + client.on('error', reject) + + client.connect({ + host: '127.0.0.1', + port: TEST_PORT, + username: TEST_USER, + password: TEST_PASS, + }) + }) +} + +/** Promise wrapper around sftp.readdir. */ +function sftpReaddir(sftp: any, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.readdir(path, (err: Error | null, list: any[]) => { + if (err) return reject(err) + resolve(list) + }) + }) +} + +/** Promise wrapper around sftp.stat. */ +function sftpStat(sftp: any, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.stat(path, (err: Error | null, stats: any) => { + if (err) return reject(err) + resolve(stats) + }) + }) +} + +/** Reads a full remote file into a string. */ +function sftpReadFile(sftp: any, path: string): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + const stream = sftp.createReadStream(path) + stream.on('data', (chunk: Buffer) => chunks.push(chunk)) + stream.on('end', () => resolve(Buffer.concat(chunks).toString())) + stream.on('error', reject) + }) +} + +/** Writes a string to a remote file. */ +function sftpWriteFile(sftp: any, path: string, content: string): Promise { + return new Promise((resolve, reject) => { + const stream = sftp.createWriteStream(path) + stream.on('close', resolve) + stream.on('error', reject) + stream.end(Buffer.from(content)) + }) +} + +/** Promise wrapper around sftp.mkdir. */ +function sftpMkdir(sftp: any, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.mkdir(path, (err: Error | null) => { + if (err) return reject(err) + resolve() + }) + }) +} + +/** Promise wrapper around sftp.unlink. */ +function sftpUnlink(sftp: any, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.unlink(path, (err: Error | null) => { + if (err) return reject(err) + resolve() + }) + }) +} + +/** Promise wrapper around sftp.rename. */ +function sftpRename(sftp: any, from: string, to: string): Promise { + return new Promise((resolve, reject) => { + sftp.rename(from, to, (err: Error | null) => { + if (err) return reject(err) + resolve() + }) + }) +} + +describe('E2E: SFTP Server', () => { + let server: SFTPServer + let homeClient: BaseClient + let storageClient: BaseClient + + const homeTree: Record = { + '/': [ + makeNode('/docs', true), + makeNode('/readme.txt', false, 42), + ], + '/docs': [ + makeNode('/docs/hello.txt', false, 13), + makeNode('/docs/world.txt', false, 11), + ], + } + + const homeFiles: Record = { + '/readme.txt': 'Hello from home!', + '/docs/hello.txt': 'Hello, World!', + '/docs/world.txt': 'World file.', + } + + const storageTree: Record = { + '/': [ + makeNode('/backups', true), + ], + '/backups': [ + makeNode('/backups/db.sql', false, 999), + ], + } + + const storageFiles: Record = { + '/backups/db.sql': 'CREATE TABLE test;', + } + + beforeAll(async () => { + mkdirSync(TEST_TEMP_DIR, { recursive: true }) + + homeClient = createMockClient('/home', homeTree, homeFiles) + storageClient = createMockClient('/storage', storageTree, storageFiles) + + // Pre-populate caches so the server can return data from cache + for (const [dir, nodes] of Object.entries(homeTree)) { + for (const node of nodes) { + await homeClient.cache.set(node.path, node) + } + } + for (const [dir, nodes] of Object.entries(storageTree)) { + for (const node of nodes) { + await storageClient.cache.set(node.path, node) + } + } + + server = new SFTPServer( + [homeClient, storageClient], + TEST_PORT, + { username: TEST_USER, password: TEST_PASS }, + TEST_HOST_KEY, + ) + + await server.start() + }) + + afterAll(async () => { + await server.stop() + rmSync(TEST_TEMP_DIR, { recursive: true, force: true }) + }) + + it('should reject invalid credentials', async () => { + await expect( + new Promise((resolve, reject) => { + const client = new SSHClient() + client.on('ready', () => reject(new Error('Should not authenticate'))) + client.on('error', (err: Error) => resolve(err)) + client.connect({ + host: '127.0.0.1', + port: TEST_PORT, + username: 'wrong', + password: 'wrong', + }) + }) + ).resolves.toBeTruthy() + }) + + it('should list virtual root directory', async () => { + const { client, sftp } = await connectSFTP() + try { + const entries = await sftpReaddir(sftp, '/') + const names = entries.map((e: any) => e.filename) + expect(names).toContain('home') + expect(names).toContain('storage') + } finally { + client.end() + } + }) + + it('should list mount point contents', async () => { + const { client, sftp } = await connectSFTP() + try { + const entries = await sftpReaddir(sftp, '/home') + const names = entries.map((e: any) => e.filename) + expect(names).toContain('docs') + expect(names).toContain('readme.txt') + } finally { + client.end() + } + }) + + it('should list nested directory contents', async () => { + const { client, sftp } = await connectSFTP() + try { + const entries = await sftpReaddir(sftp, '/home/docs') + const names = entries.map((e: any) => e.filename) + expect(names).toContain('hello.txt') + expect(names).toContain('world.txt') + } finally { + client.end() + } + }) + + it('should stat the root directory', async () => { + const { client, sftp } = await connectSFTP() + try { + const stats = await sftpStat(sftp, '/') + expect(stats.isDirectory()).toBe(true) + } finally { + client.end() + } + }) + + it('should stat a file', async () => { + const { client, sftp } = await connectSFTP() + try { + const stats = await sftpStat(sftp, '/home/readme.txt') + expect(stats.isDirectory()).toBe(false) + expect(stats.size).toBe(42) + } finally { + client.end() + } + }) + + it('should read a file', async () => { + const { client, sftp } = await connectSFTP() + try { + const content = await sftpReadFile(sftp, '/home/readme.txt') + expect(content).toBe('Hello from home!') + } finally { + client.end() + } + }) + + it('should read a file from a nested directory', async () => { + const { client, sftp } = await connectSFTP() + try { + const content = await sftpReadFile(sftp, '/home/docs/hello.txt') + expect(content).toBe('Hello, World!') + } finally { + client.end() + } + }) + + it('should read a file from the storage mount', async () => { + const { client, sftp } = await connectSFTP() + try { + const content = await sftpReadFile(sftp, '/storage/backups/db.sql') + expect(content).toBe('CREATE TABLE test;') + } finally { + client.end() + } + }) + + it('should write a file', async () => { + const { client, sftp } = await connectSFTP() + try { + await sftpWriteFile(sftp, '/home/new-file.txt', 'New content!') + expect(homeClient.write).toHaveBeenCalled() + } finally { + client.end() + } + }) + + it('should create a directory', async () => { + const { client, sftp } = await connectSFTP() + try { + await sftpMkdir(sftp, '/home/new-dir') + expect(homeClient.mkdir).toHaveBeenCalledWith('/new-dir') + } finally { + client.end() + } + }) + + it('should delete a file', async () => { + const { client, sftp } = await connectSFTP() + try { + await sftpUnlink(sftp, '/home/readme.txt') + expect(homeClient.delete).toHaveBeenCalledWith('/readme.txt') + } finally { + client.end() + } + }) + + it('should rename a file', async () => { + const { client, sftp } = await connectSFTP() + try { + await sftpRename(sftp, '/home/readme.txt', '/home/renamed.txt') + expect(homeClient.rename).toHaveBeenCalledWith('/readme.txt', '/renamed.txt') + } finally { + client.end() + } + }) + + it('should resolve REALPATH', async () => { + const { client, sftp } = await connectSFTP() + try { + const realpath = await new Promise((resolve, reject) => { + sftp.realpath('/home/../home/docs', (err: Error | null, path: string) => { + if (err) return reject(err) + resolve(path) + }) + }) + expect(realpath).toBe('/home/docs') + } finally { + client.end() + } + }) +}) diff --git a/src/__tests__/helpers.ts b/src/__tests__/helpers.ts new file mode 100644 index 0000000..1c7226b --- /dev/null +++ b/src/__tests__/helpers.ts @@ -0,0 +1,110 @@ +import { vi } from 'vitest' +import { Readable } from 'node:stream' +import { MemoryCache } from '../adapters/cache/memory.js' +import type { BaseClient } from '../adapters/client/base.js' +import type { GenericNode } from '../types.js' +import { SyncWorker } from '../sync.js' + +/** Creates a GenericNode for testing. */ +export function makeNode(path: string, isDir = false, size = 1024): GenericNode { + const parts = path.split('/') + return { + path, + name: parts[parts.length - 1] || '/', + isDir, + size: isDir ? 0 : size, + modified: new Date('2025-06-01T12:00:00Z'), + etag: undefined, + } +} + +/** + * Creates a mock client backed by an in-memory cache. + * `tree` maps directory paths to their children, `fileContents` maps file paths to content strings. + */ +export function createMockClient( + mountPath: string, + tree: Record, + fileContents: Record = {}, +): BaseClient { + const cache = new MemoryCache() + + const mockClient = { + mountPath, + cache, + sync: { + start: vi.fn(), + stop: vi.fn(), + forceSync: vi.fn().mockResolvedValue(undefined), + prioritise: vi.fn(), + waitForDrain: vi.fn().mockResolvedValue(undefined), + } as unknown as SyncWorker, + + list: vi.fn(async (path: string) => { + return tree[path] ?? [] + }), + + stat: vi.fn(async (path: string) => { + for (const nodes of Object.values(tree)) { + const found = nodes.find(n => n.path === path) + if (found) return found + } + throw new Error(`Not found: ${path}`) + }), + + read: vi.fn(async (path: string) => { + const content = fileContents[path] ?? `content of ${path}` + return Readable.from(Buffer.from(content)) + }), + + write: vi.fn(async () => {}), + mkdir: vi.fn(async () => {}), + delete: vi.fn(async () => {}), + rename: vi.fn(async () => {}), + } + + return mockClient as unknown as BaseClient +} + +/** Populates a mock client's cache from its tree definition. */ +export async function populateCache(client: BaseClient, tree: Record): Promise { + for (const nodes of Object.values(tree)) { + for (const node of nodes) { + await client.cache.set(node.path, node) + } + } +} + +/** Standard test tree for the `/home` mount. */ +export const HOME_TREE: Record = { + '/': [ + makeNode('/docs', true), + makeNode('/readme.txt', false, 42), + ], + '/docs': [ + makeNode('/docs/hello.txt', false, 13), + makeNode('/docs/world.txt', false, 11), + ], +} + +/** Standard file contents for the `/home` mount. */ +export const HOME_FILES: Record = { + '/readme.txt': 'Hello from home!', + '/docs/hello.txt': 'Hello, World!', + '/docs/world.txt': 'World file.', +} + +/** Standard test tree for the `/storage` mount. */ +export const STORAGE_TREE: Record = { + '/': [ + makeNode('/backups', true), + ], + '/backups': [ + makeNode('/backups/db.sql', false, 999), + ], +} + +/** Standard file contents for the `/storage` mount. */ +export const STORAGE_FILES: Record = { + '/backups/db.sql': 'CREATE TABLE test;', +} diff --git a/src/__tests__/sync.test.ts b/src/__tests__/sync.test.ts new file mode 100644 index 0000000..43a9f39 --- /dev/null +++ b/src/__tests__/sync.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { SyncWorker, type SyncableClient } from '../sync.js' +import { MemoryCache } from '../adapters/cache/memory.js' +import type { GenericNode } from '../types.js' + +/** Suppress logger output during tests. */ +vi.mock('../logger.js', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})) + +function makeNode(path: string, isDir = false): GenericNode { + const parts = path.split('/') + return { + path, + name: parts[parts.length - 1] || '/', + isDir, + size: isDir ? 0 : 1024, + modified: new Date('2025-01-01'), + etag: `etag-${path}`, + } +} + +/** + * Creates a mock client that returns a pre-configured directory tree. + * The tree is a map from directory path to its direct children. + */ +function createMockClient(tree: Record): SyncableClient { + return { + list: vi.fn(async (path: string) => { + return tree[path] ?? [] + }), + } +} + +describe('SyncWorker', () => { + let cache: MemoryCache + + beforeEach(() => { + cache = new MemoryCache() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should sync root directory contents to cache', async () => { + const tree: Record = { + '/': [ + makeNode('/docs', true), + makeNode('/readme.txt'), + ], + '/docs': [ + makeNode('/docs/file.txt'), + ], + } + + const client = createMockClient(tree) + const worker = new SyncWorker(client, cache, 2) + + worker.start() + await worker.waitForDrain() + worker.stop() + + expect(await cache.get('/docs')).toBeTruthy() + expect(await cache.get('/readme.txt')).toBeTruthy() + expect(await cache.get('/docs/file.txt')).toBeTruthy() + }) + + it('should remove deleted nodes from cache', async () => { + // Pre-populate cache with a node that no longer exists on remote + await cache.set('/old-file.txt', makeNode('/old-file.txt')) + + const tree: Record = { + '/': [makeNode('/new-file.txt')], + } + + const client = createMockClient(tree) + const worker = new SyncWorker(client, cache, 1) + + worker.start() + await worker.waitForDrain() + worker.stop() + + expect(await cache.get('/old-file.txt')).toBeNull() + expect(await cache.get('/new-file.txt')).toBeTruthy() + }) + + it('should update changed nodes based on etag', async () => { + const originalNode = makeNode('/file.txt') + originalNode.etag = 'old-etag' + await cache.set('/file.txt', originalNode) + + const updatedNode = makeNode('/file.txt') + updatedNode.etag = 'new-etag' + + const tree: Record = { + '/': [updatedNode], + } + + const client = createMockClient(tree) + const worker = new SyncWorker(client, cache, 1) + + worker.start() + await worker.waitForDrain() + worker.stop() + + const cached = await cache.get('/file.txt') + expect(cached?.etag).toBe('new-etag') + }) + + it('should handle forceSync', async () => { + const tree: Record = { + '/': [makeNode('/docs', true)], + '/docs': [makeNode('/docs/new.txt')], + } + + const client = createMockClient(tree) + const worker = new SyncWorker(client, cache, 2) + + worker.start() + await worker.forceSync('/docs') + worker.stop() + + expect(await cache.get('/docs/new.txt')).toBeTruthy() + }) + + it('should handle prioritise without blocking', async () => { + const tree: Record = { + '/': [makeNode('/docs', true)], + '/docs': [makeNode('/docs/file.txt')], + } + + const client = createMockClient(tree) + const worker = new SyncWorker(client, cache, 2) + + worker.start() + worker.prioritise('/docs') + await worker.waitForDrain() + worker.stop() + + expect(await cache.get('/docs/file.txt')).toBeTruthy() + }) + + it('should recursively sync nested directories', async () => { + const tree: Record = { + '/': [makeNode('/a', true)], + '/a': [makeNode('/a/b', true)], + '/a/b': [makeNode('/a/b/c', true)], + '/a/b/c': [makeNode('/a/b/c/deep.txt')], + } + + const client = createMockClient(tree) + const worker = new SyncWorker(client, cache, 1) + + worker.start() + await worker.waitForDrain() + worker.stop() + + expect(await cache.get('/a/b/c/deep.txt')).toBeTruthy() + }) + + it('should handle empty directories', async () => { + const tree: Record = { + '/': [makeNode('/empty', true)], + '/empty': [], + } + + const client = createMockClient(tree) + const worker = new SyncWorker(client, cache, 1) + + worker.start() + await worker.waitForDrain() + worker.stop() + + const children = await cache.children('/empty') + expect(children).toHaveLength(0) + }) + + it('should handle client.list errors gracefully', async () => { + const client: SyncableClient = { + list: vi.fn(async () => { + throw new Error('Network failure') + }), + } + + const worker = new SyncWorker(client, cache, 1) + worker.start() + await worker.waitForDrain() + worker.stop() + + // Should not throw, just log the error + expect(await cache.all()).toHaveLength(0) + }) +}) diff --git a/src/__tests__/types.test.ts b/src/__tests__/types.test.ts new file mode 100644 index 0000000..a4a07c4 --- /dev/null +++ b/src/__tests__/types.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest' +import { ConfigSchema } from '../types.js' + +describe('ConfigSchema', () => { + const validConfig = { + port: 2022, + credentials: { + username: 'localuser', + password: 'localpassword', + }, + clients: [ + { + type: 'webdav' as const, + url: 'https://files.example.com', + basePath: '/', + username: 'user', + password: 'pass', + mountPath: '/home', + concurrency: 5, + cache: { type: 'sqlite' as const }, + }, + ], + } + + it('should accept a valid config', () => { + const result = ConfigSchema.safeParse(validConfig) + expect(result.success).toBe(true) + }) + + it('should apply default values', () => { + const minimal = { + credentials: { + username: 'user', + password: 'pass', + }, + clients: [ + { + type: 'webdav', + url: 'https://example.com', + username: 'u', + password: 'p', + mountPath: '/mount', + }, + ], + } + const result = ConfigSchema.safeParse(minimal) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.port).toBe(2022) + expect(result.data.clients[0].concurrency).toBe(5) + expect(result.data.clients[0].basePath).toBe('/') + expect(result.data.clients[0].cache.type).toBe('sqlite') + } + }) + + it('should reject config with no clients', () => { + const noClients = { + ...validConfig, + clients: [], + } + const result = ConfigSchema.safeParse(noClients) + expect(result.success).toBe(false) + }) + + it('should reject config with missing credentials', () => { + const { credentials, ...rest } = validConfig + const result = ConfigSchema.safeParse(rest) + expect(result.success).toBe(false) + }) + + it('should reject config with invalid client type', () => { + const badType = { + ...validConfig, + clients: [{ ...validConfig.clients[0], type: 'invalid' }], + } + const result = ConfigSchema.safeParse(badType) + expect(result.success).toBe(false) + }) + + it('should accept all valid client types', () => { + for (const type of ['webdav', 'ftp', 'sftp'] as const) { + const config = { + ...validConfig, + clients: [{ ...validConfig.clients[0], type }], + } + const result = ConfigSchema.safeParse(config) + expect(result.success).toBe(true) + } + }) + + it('should accept optional servers config', () => { + const withServers = { + ...validConfig, + servers: { + sftp: { port: 2022 }, + ftp: { port: 2121 }, + webdav: { port: 8080 }, + }, + } + const result = ConfigSchema.safeParse(withServers) + expect(result.success).toBe(true) + }) +}) diff --git a/src/adapters/cache/__tests__/memory.test.ts b/src/adapters/cache/__tests__/memory.test.ts new file mode 100644 index 0000000..c975213 --- /dev/null +++ b/src/adapters/cache/__tests__/memory.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { MemoryCache } from '../memory.js' +import type { GenericNode } from '../../../types.js' + +function makeNode(path: string, isDir = false): GenericNode { + const parts = path.split('/') + return { + path, + name: parts[parts.length - 1] || '/', + isDir, + size: isDir ? 0 : 1024, + modified: new Date('2025-01-01'), + etag: undefined, + } +} + +describe('MemoryCache', () => { + let cache: MemoryCache + + beforeEach(() => { + cache = new MemoryCache() + }) + + it('should return null for missing keys', async () => { + expect(await cache.get('/nonexistent')).toBeNull() + }) + + it('should set and get a node', async () => { + const node = makeNode('/docs/file.txt') + await cache.set('/docs/file.txt', node) + const result = await cache.get('/docs/file.txt') + expect(result).toEqual(node) + }) + + it('should delete a node', async () => { + await cache.set('/docs/file.txt', makeNode('/docs/file.txt')) + await cache.delete('/docs/file.txt') + expect(await cache.get('/docs/file.txt')).toBeNull() + }) + + it('should return all nodes', async () => { + await cache.set('/a.txt', makeNode('/a.txt')) + await cache.set('/b.txt', makeNode('/b.txt')) + const all = await cache.all() + expect(all).toHaveLength(2) + }) + + it('should return all keys', async () => { + await cache.set('/a.txt', makeNode('/a.txt')) + await cache.set('/b.txt', makeNode('/b.txt')) + const keys = await cache.keys() + expect(keys).toContain('/a.txt') + expect(keys).toContain('/b.txt') + }) + + it('should clear all nodes', async () => { + await cache.set('/a.txt', makeNode('/a.txt')) + await cache.set('/b.txt', makeNode('/b.txt')) + await cache.clear() + expect(await cache.all()).toHaveLength(0) + }) + + describe('children', () => { + beforeEach(async () => { + await cache.set('/docs', makeNode('/docs', true)) + await cache.set('/docs/a.txt', makeNode('/docs/a.txt')) + await cache.set('/docs/b.txt', makeNode('/docs/b.txt')) + await cache.set('/docs/sub', makeNode('/docs/sub', true)) + await cache.set('/docs/sub/c.txt', makeNode('/docs/sub/c.txt')) + await cache.set('/other', makeNode('/other', true)) + await cache.set('/other/d.txt', makeNode('/other/d.txt')) + }) + + it('should return direct children of /docs', async () => { + const children = await cache.children('/docs') + const paths = children.map(n => n.path) + expect(paths).toContain('/docs/a.txt') + expect(paths).toContain('/docs/b.txt') + expect(paths).toContain('/docs/sub') + expect(paths).not.toContain('/docs/sub/c.txt') + expect(paths).not.toContain('/other/d.txt') + }) + + it('should return direct children of root (/)', async () => { + const children = await cache.children('/') + const paths = children.map(n => n.path) + expect(paths).toContain('/docs') + expect(paths).toContain('/other') + expect(paths).not.toContain('/docs/a.txt') + }) + + it('should return direct children of /docs/sub', async () => { + const children = await cache.children('/docs/sub') + const paths = children.map(n => n.path) + expect(paths).toEqual(['/docs/sub/c.txt']) + }) + + it('should return empty array for path with no children', async () => { + const children = await cache.children('/empty') + expect(children).toHaveLength(0) + }) + + it('should handle trailing slashes', async () => { + const children = await cache.children('/docs/') + const paths = children.map(n => n.path) + expect(paths).toContain('/docs/a.txt') + }) + }) +}) diff --git a/src/adapters/cache/base.ts b/src/adapters/cache/base.ts new file mode 100644 index 0000000..90d83a3 --- /dev/null +++ b/src/adapters/cache/base.ts @@ -0,0 +1,32 @@ +import type { GenericNode } from '../../types.js' + +/** + * Abstract base class for all cache implementations. + * Keys are always the full POSIX path of the node, e.g. `/docs/file.txt`. + * All methods are async even if the underlying implementation is synchronous. + */ +export abstract class BaseCache { + /** Retrieve a cached node by its path key. */ + abstract get(key: string): Promise + + /** Store a node in the cache, keyed by its path. */ + abstract set(key: string, value: GenericNode): Promise + + /** Remove a node from the cache by its path key. */ + abstract delete(key: string): Promise + + /** Return all cached nodes. */ + abstract all(): Promise + + /** Return all cached keys. */ + abstract keys(): Promise + + /** + * Return direct children of the given path — one level deep only. + * For path `/docs`, this returns nodes like `/docs/file.txt` but NOT `/docs/sub/file.txt`. + */ + abstract children(path: string): Promise + + /** Remove all entries from the cache. */ + abstract clear(): Promise +} diff --git a/src/adapters/cache/memory.ts b/src/adapters/cache/memory.ts new file mode 100644 index 0000000..663b311 --- /dev/null +++ b/src/adapters/cache/memory.ts @@ -0,0 +1,53 @@ +import { posix } from 'node:path' +import type { GenericNode } from '../../types.js' +import { BaseCache } from './base.js' + +/** + * In-memory cache implementation backed by a simple Map. + * Primarily useful for testing and lightweight use cases. + */ +export class MemoryCache extends BaseCache { + private store = new Map() + + async get(key: string): Promise { + return this.store.get(key) ?? null + } + + async set(key: string, value: GenericNode): Promise { + this.store.set(key, value) + } + + async delete(key: string): Promise { + this.store.delete(key) + } + + async all(): Promise { + return Array.from(this.store.values()) + } + + async keys(): Promise { + return Array.from(this.store.keys()) + } + + /** + * Returns direct children of the given path — one level deep only. + * Filters keys whose dirname matches the normalised parent path. + */ + async children(path: string): Promise { + const normalised = path === '/' ? '/' : path.replace(/\/+$/, '') + const result: GenericNode[] = [] + + for (const [key, node] of this.store) { + const parent = posix.dirname(key) + if (parent === normalised && key !== normalised) { + result.push(node) + } + } + + return result + } + + async clear(): Promise { + this.store.clear() + } +} diff --git a/src/adapters/cache/sqlite.ts b/src/adapters/cache/sqlite.ts new file mode 100644 index 0000000..7694e22 --- /dev/null +++ b/src/adapters/cache/sqlite.ts @@ -0,0 +1,142 @@ +import { mkdirSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { homedir } from 'node:os' +import Database from 'better-sqlite3' +import type { GenericNode } from '../../types.js' +import { BaseCache } from './base.js' + +/** Default path for the SQLite database file. */ +const DEFAULT_DB_PATH = join(homedir(), '.config', 'sftp-proxy', 'cache.db') + +/** + * SQLite-backed cache implementation. + * Stores node metadata in a `nodes` table and log entries in a `logs` table. + */ +export class SqliteCache extends BaseCache { + private db: Database.Database + + /** + * @param dbPath - Path to the SQLite database file. Defaults to ~/.config/sftp-proxy/cache.db + */ + constructor(dbPath: string = DEFAULT_DB_PATH) { + super() + + mkdirSync(dirname(dbPath), { recursive: true }) + this.db = new Database(dbPath) + this.db.pragma('journal_mode = WAL') + this.initSchema() + } + + /** Creates tables if they don't already exist. */ + private initSchema(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS nodes ( + path TEXT PRIMARY KEY, + name TEXT NOT NULL, + is_dir INTEGER NOT NULL, + size INTEGER NOT NULL, + modified INTEGER NOT NULL, + etag TEXT, + synced_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp INTEGER NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL + ); + `) + } + + /** Maps a database row to a GenericNode. */ + private rowToNode(row: Record): GenericNode { + return { + path: row['path'] as string, + name: row['name'] as string, + isDir: (row['is_dir'] as number) === 1, + size: row['size'] as number, + modified: new Date(row['modified'] as number), + etag: (row['etag'] as string) ?? undefined, + } + } + + async get(key: string): Promise { + const row = this.db.prepare('SELECT * FROM nodes WHERE path = ?').get(key) as Record | undefined + return row ? this.rowToNode(row) : null + } + + async set(key: string, value: GenericNode): Promise { + this.db.prepare(` + INSERT OR REPLACE INTO nodes (path, name, is_dir, size, modified, etag, synced_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + key, + value.name, + value.isDir ? 1 : 0, + value.size, + value.modified.getTime(), + value.etag ?? null, + Date.now(), + ) + } + + async delete(key: string): Promise { + this.db.prepare('DELETE FROM nodes WHERE path = ?').run(key) + } + + async all(): Promise { + const rows = this.db.prepare('SELECT * FROM nodes').all() as Record[] + return rows.map(row => this.rowToNode(row)) + } + + async keys(): Promise { + const rows = this.db.prepare('SELECT path FROM nodes').all() as Array<{ path: string }> + return rows.map(row => row.path) + } + + /** + * Returns direct children of the given path — one level deep only. + * Uses LIKE with a NOT LIKE nested-exclusion pattern. + */ + async children(path: string): Promise { + const normalised = path === '/' ? '' : path.replace(/\/+$/, '') + const prefix = normalised + '/' + const nestedPrefix = normalised + '/%/%' + + const rows = this.db.prepare( + `SELECT * FROM nodes WHERE path LIKE ? AND path NOT LIKE ?` + ).all(prefix + '%', nestedPrefix) as Record[] + + return rows.map(row => this.rowToNode(row)) + } + + async clear(): Promise { + this.db.prepare('DELETE FROM nodes').run() + } + + /** + * Writes a log entry into the logs table. + * Used by the winston SQLite transport. + */ + writeLog(level: string, message: string): void { + this.db.prepare( + 'INSERT INTO logs (timestamp, level, message) VALUES (?, ?, ?)' + ).run(Date.now(), level, message) + } + + /** + * Reads the most recent log entries. + * @param limit - Maximum number of entries to return. Defaults to 500. + */ + readLogs(limit: number = 500): Array<{ timestamp: number; level: string; message: string }> { + return this.db.prepare( + 'SELECT timestamp, level, message FROM logs ORDER BY id DESC LIMIT ?' + ).all(limit) as Array<{ timestamp: number; level: string; message: string }> + } + + /** Closes the database connection. */ + close(): void { + this.db.close() + } +} diff --git a/src/adapters/client/base.ts b/src/adapters/client/base.ts new file mode 100644 index 0000000..f546fcd --- /dev/null +++ b/src/adapters/client/base.ts @@ -0,0 +1,62 @@ +import type { Readable } from 'node:stream' +import type { GenericNode, ClientConfig } from '../../types.js' +import type { BaseCache } from '../cache/base.js' +import { SyncWorker } from '../../sync.js' + +/** + * Abstract base class for all remote storage clients. + * + * Each client owns its own cache and sync worker. All paths passed to + * client methods are relative to the configured `basePath` on the remote server. + * + * The client stores `mountPath` as data but never uses it for logic — + * that's purely a server concern for dispatch. + */ +export abstract class BaseClient { + /** Where this client appears in the local virtual filesystem. Server-only concern. */ + readonly mountPath: string + /** Cache instance owned by this client. */ + readonly cache: BaseCache + /** Background sync worker that keeps the cache fresh. */ + readonly sync: SyncWorker + + constructor( + protected config: ClientConfig, + cache: BaseCache, + ) { + this.mountPath = config.mountPath + this.cache = cache + this.sync = new SyncWorker(this, cache, config.concurrency) + } + + /** Lists direct children at the given remote path (metadata only). */ + abstract list(path: string): Promise + + /** Returns metadata for a single remote path. */ + abstract stat(path: string): Promise + + /** Returns a readable stream of the file contents at the given remote path. */ + abstract read(path: string): Promise + + /** Writes the stream contents to the given remote path. */ + abstract write(path: string, stream: Readable): Promise + + /** Creates a directory at the given remote path. */ + abstract mkdir(path: string): Promise + + /** Deletes the file or directory at the given remote path. */ + abstract delete(path: string): Promise + + /** Renames/moves a file or directory from one remote path to another. */ + abstract rename(from: string, to: string): Promise + + /** + * Resolves a path relative to the client's basePath into a full remote path. + * Normalises slashes and ensures a leading slash. + */ + protected resolvePath(relativePath: string): string { + const base = this.config.basePath.replace(/\/+$/, '') + const rel = relativePath.startsWith('/') ? relativePath : `/${relativePath}` + return `${base}${rel}` + } +} diff --git a/src/adapters/client/ftp.ts b/src/adapters/client/ftp.ts new file mode 100644 index 0000000..b1318db --- /dev/null +++ b/src/adapters/client/ftp.ts @@ -0,0 +1,126 @@ +import { Readable, PassThrough } from 'node:stream' +import { posix } from 'node:path' +import * as ftp from 'basic-ftp' +import type { GenericNode, ClientConfig } from '../../types.js' +import type { BaseCache } from '../cache/base.js' +import { BaseClient } from './base.js' +import { logger } from '../../logger.js' + +/** + * FTP remote storage client. + * + * Maintains a persistent connection and reconnects on failure. + * Uses `basic-ftp` for all operations. + */ +export class FTPClientAdapter extends BaseClient { + private client: ftp.Client + private connected = false + + constructor(config: ClientConfig, cache: BaseCache) { + super(config, cache) + this.client = new ftp.Client() + logger.info(`FTP client created for ${config.url} (mount: ${config.mountPath})`) + } + + /** + * Ensures the FTP client is connected. Reconnects if the connection was lost. + */ + private async ensureConnected(): Promise { + if (this.connected && !this.client.closed) return + + try { + const url = new URL(this.config.url) + await this.client.access({ + host: url.hostname, + port: url.port ? parseInt(url.port, 10) : 21, + user: this.config.username, + password: this.config.password, + secure: url.protocol === 'ftps:', + }) + this.connected = true + logger.info(`FTP connected to ${this.config.url}`) + } catch (err) { + this.connected = false + throw err + } + } + + /** Maps an FTP FileInfo to our GenericNode format. */ + private toNode(info: ftp.FileInfo, parentPath: string): GenericNode { + const nodePath = posix.join(parentPath, info.name) + return { + path: nodePath, + name: info.name, + isDir: info.isDirectory, + size: info.size, + modified: info.modifiedAt ?? new Date(0), + etag: undefined, + } + } + + async list(path: string): Promise { + await this.ensureConnected() + const remotePath = this.resolvePath(path) + const items = await this.client.list(remotePath) + + return items + .filter(item => item.name !== '.' && item.name !== '..') + .map(item => this.toNode(item, path)) + } + + async stat(path: string): Promise { + await this.ensureConnected() + const remotePath = this.resolvePath(path) + const parentDir = posix.dirname(remotePath) + const basename = posix.basename(remotePath) + + // FTP has no direct stat — list the parent and find the entry + const items = await this.client.list(parentDir) + const match = items.find(i => i.name === basename) + + if (!match) { + throw new Error(`FTP: path not found: ${path}`) + } + + return this.toNode(match, posix.dirname(path)) + } + + async read(path: string): Promise { + await this.ensureConnected() + const remotePath = this.resolvePath(path) + const passthrough = new PassThrough() + await this.client.downloadTo(passthrough, remotePath) + return passthrough + } + + async write(path: string, stream: Readable): Promise { + await this.ensureConnected() + const remotePath = this.resolvePath(path) + await this.client.uploadFrom(stream, remotePath) + } + + async mkdir(path: string): Promise { + await this.ensureConnected() + const remotePath = this.resolvePath(path) + await this.client.ensureDir(remotePath) + } + + async delete(path: string): Promise { + await this.ensureConnected() + const remotePath = this.resolvePath(path) + + // Try removing as file first, then as directory + try { + await this.client.remove(remotePath) + } catch { + await this.client.removeDir(remotePath) + } + } + + async rename(from: string, to: string): Promise { + await this.ensureConnected() + const remoteFrom = this.resolvePath(from) + const remoteTo = this.resolvePath(to) + await this.client.rename(remoteFrom, remoteTo) + } +} diff --git a/src/adapters/client/sftp.ts b/src/adapters/client/sftp.ts new file mode 100644 index 0000000..442cdc7 --- /dev/null +++ b/src/adapters/client/sftp.ts @@ -0,0 +1,198 @@ +import { Readable, PassThrough } from 'node:stream' +import { posix } from 'node:path' +import ssh2 from 'ssh2' +const { Client: SSHClient } = ssh2 +type SFTPWrapper = ssh2.SFTPWrapper +type ConnectConfig = ssh2.ConnectConfig +import type { GenericNode, ClientConfig } from '../../types.js' +import type { BaseCache } from '../cache/base.js' +import { BaseClient } from './base.js' +import { logger } from '../../logger.js' + +/** + * SFTP remote storage client. + * + * Maintains a persistent SSH connection with an SFTP subsystem. + * Reconnects automatically on failure. + */ +export class SFTPClientAdapter extends BaseClient { + private sshClient: InstanceType | null = null + private sftp: SFTPWrapper | null = null + private connected = false + + constructor(config: ClientConfig, cache: BaseCache) { + super(config, cache) + logger.info(`SFTP client created for ${config.url} (mount: ${config.mountPath})`) + } + + /** + * Ensures the SFTP session is connected. Reconnects if the connection was lost. + */ + private async ensureConnected(): Promise { + if (this.connected && this.sftp) return this.sftp + + return new Promise((resolve, reject) => { + this.cleanup() + + const url = new URL(this.config.url) + const client = new SSHClient() + + const connectConfig: ConnectConfig = { + host: url.hostname, + port: url.port ? parseInt(url.port, 10) : 22, + username: this.config.username, + password: this.config.password, + } + + client.on('ready', () => { + client.sftp((err, sftp) => { + if (err) { + client.end() + return reject(err) + } + this.sshClient = client + this.sftp = sftp + this.connected = true + logger.info(`SFTP connected to ${this.config.url}`) + resolve(sftp) + }) + }) + + client.on('error', (err) => { + this.connected = false + reject(err) + }) + + client.on('close', () => { + this.connected = false + this.sftp = null + }) + + client.connect(connectConfig) + }) + } + + /** Tears down any existing SSH connection. */ + private cleanup(): void { + if (this.sshClient) { + this.sshClient.end() + this.sshClient = null + } + this.sftp = null + this.connected = false + } + + /** Maps an SFTP file stat to our GenericNode format. */ + private toNode( + name: string, + attrs: { size: number; mtime: number; isDirectory: () => boolean }, + parentPath: string, + ): GenericNode { + const nodePath = posix.join(parentPath, name) + return { + path: nodePath, + name, + isDir: attrs.isDirectory(), + size: attrs.size, + modified: new Date(attrs.mtime * 1000), + etag: undefined, + } + } + + async list(path: string): Promise { + const sftp = await this.ensureConnected() + const remotePath = this.resolvePath(path) + + return new Promise((resolve, reject) => { + sftp.readdir(remotePath, (err, list) => { + if (err) return reject(err) + + const nodes = list + .filter(entry => entry.filename !== '.' && entry.filename !== '..') + .map(entry => this.toNode(entry.filename, entry.attrs as any, path)) + + resolve(nodes) + }) + }) + } + + async stat(path: string): Promise { + const sftp = await this.ensureConnected() + const remotePath = this.resolvePath(path) + + return new Promise((resolve, reject) => { + sftp.stat(remotePath, (err, attrs) => { + if (err) return reject(err) + + resolve({ + path, + name: posix.basename(path) || '/', + isDir: (attrs as any).isDirectory(), + size: attrs.size, + modified: new Date(attrs.mtime * 1000), + etag: undefined, + }) + }) + }) + } + + async read(path: string): Promise { + const sftp = await this.ensureConnected() + const remotePath = this.resolvePath(path) + return sftp.createReadStream(remotePath) + } + + async write(path: string, stream: Readable): Promise { + const sftp = await this.ensureConnected() + const remotePath = this.resolvePath(path) + + return new Promise((resolve, reject) => { + const writeStream = sftp.createWriteStream(remotePath) + stream.pipe(writeStream) + writeStream.on('close', resolve) + writeStream.on('error', reject) + stream.on('error', reject) + }) + } + + async mkdir(path: string): Promise { + const sftp = await this.ensureConnected() + const remotePath = this.resolvePath(path) + + return new Promise((resolve, reject) => { + sftp.mkdir(remotePath, (err) => { + if (err) return reject(err) + resolve() + }) + }) + } + + async delete(path: string): Promise { + const sftp = await this.ensureConnected() + const remotePath = this.resolvePath(path) + + // Try unlink (file) first, then rmdir (directory) + return new Promise((resolve, reject) => { + sftp.unlink(remotePath, (err) => { + if (!err) return resolve() + sftp.rmdir(remotePath, (rmdirErr) => { + if (rmdirErr) return reject(rmdirErr) + resolve() + }) + }) + }) + } + + async rename(from: string, to: string): Promise { + const sftp = await this.ensureConnected() + const remoteFrom = this.resolvePath(from) + const remoteTo = this.resolvePath(to) + + return new Promise((resolve, reject) => { + sftp.rename(remoteFrom, remoteTo, (err) => { + if (err) return reject(err) + resolve() + }) + }) + } +} diff --git a/src/adapters/client/webdav.ts b/src/adapters/client/webdav.ts new file mode 100644 index 0000000..af0a7bf --- /dev/null +++ b/src/adapters/client/webdav.ts @@ -0,0 +1,127 @@ +import { Readable } from 'node:stream' +import { posix } from 'node:path' +import { createClient, type WebDAVClient, type FileStat } from 'webdav' +import type { GenericNode, ClientConfig } from '../../types.js' +import type { BaseCache } from '../cache/base.js' +import { BaseClient } from './base.js' +import { logger } from '../../logger.js' + +/** + * WebDAV remote storage client. + * + * Uses `PROPFIND Depth: 1` for listings — never assumes `Depth: infinity` is supported. + * Never fetches file contents during sync, only metadata. + */ +export class WebDAVClientAdapter extends BaseClient { + private webdav: WebDAVClient + + constructor(config: ClientConfig, cache: BaseCache) { + super(config, cache) + + this.webdav = createClient(config.url, { + username: config.username, + password: config.password, + }) + + logger.info(`WebDAV client created for ${config.url} (mount: ${config.mountPath})`) + } + + /** Maps a webdav FileStat to our GenericNode format. */ + private toNode(stat: FileStat, parentPath: string): GenericNode { + const nodePath = posix.join(parentPath, stat.basename) + return { + path: nodePath, + name: stat.basename, + isDir: stat.type === 'directory', + size: stat.size, + modified: new Date(stat.lastmod), + etag: stat.etag ?? undefined, + } + } + + async list(path: string): Promise { + const remotePath = this.resolvePath(path) + const contents = await this.webdav.getDirectoryContents(remotePath, { deep: false }) as FileStat[] + + // The webdav package's built-in self-reference filter breaks when the server + // has a non-root base path (e.g. Nextcloud's /remote.php/dav/files/user). + // It compares stat.filename (relativized) against the request path (absolute), + // so they never match and the directory itself leaks into the results. + // + // stat.filename is either: + // - absolute like "/Media" (when serverBase is "/") + // - relative like "Media" (when serverBase is a deeper path) + // + // We normalise both sides and also compare basenames to catch all variants. + const normRemote = remotePath.replace(/\/+$/, '') || '/' + const parentBasename = posix.basename(normRemote) + + const children = contents.filter(stat => { + // Empty basename = root self-reference + if (!stat.basename) return false + + const fn = (stat.filename || '').replace(/\/+$/, '') || '/' + + // Absolute match (serverBase is "/") + if (fn === normRemote) return false + + // Relative match (serverBase is deeper — filename has no leading slash) + if ('/' + fn === normRemote) return false + if (fn === normRemote.slice(1)) return false + + // Directory whose basename matches the listed directory AND whose + // filename doesn't contain a slash (i.e. it's a single path segment, + // meaning it's the directory itself, not a nested child) + if ( + stat.type === 'directory' && + stat.basename === parentBasename && + !stat.filename.includes('/') + ) return false + + return true + }) + + return children.map(stat => this.toNode(stat, path)) + } + + async stat(path: string): Promise { + const remotePath = this.resolvePath(path) + const result = await this.webdav.stat(remotePath) as FileStat + + return { + path, + name: posix.basename(path) || '/', + isDir: result.type === 'directory', + size: result.size, + modified: new Date(result.lastmod), + etag: result.etag ?? undefined, + } + } + + async read(path: string): Promise { + const remotePath = this.resolvePath(path) + const stream = this.webdav.createReadStream(remotePath) + return Readable.from(stream) + } + + async write(path: string, stream: Readable): Promise { + const remotePath = this.resolvePath(path) + await this.webdav.putFileContents(remotePath, stream) + } + + async mkdir(path: string): Promise { + const remotePath = this.resolvePath(path) + await this.webdav.createDirectory(remotePath) + } + + async delete(path: string): Promise { + const remotePath = this.resolvePath(path) + await this.webdav.deleteFile(remotePath) + } + + async rename(from: string, to: string): Promise { + const remoteFrom = this.resolvePath(from) + const remoteTo = this.resolvePath(to) + await this.webdav.moveFile(remoteFrom, remoteTo) + } +} diff --git a/src/adapters/server/__tests__/base.test.ts b/src/adapters/server/__tests__/base.test.ts new file mode 100644 index 0000000..ae951b8 --- /dev/null +++ b/src/adapters/server/__tests__/base.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { BaseServer } from '../base.js' +import { MemoryCache } from '../../cache/memory.js' +import type { BaseClient } from '../../client/base.js' +import type { GenericNode } from '../../../types.js' +import { SyncWorker } from '../../../sync.js' + +/** Suppress logger output during tests. */ +vi.mock('../../../logger.js', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, +})) + +function makeNode(path: string, isDir = false): GenericNode { + const parts = path.split('/') + return { + path, + name: parts[parts.length - 1] || '/', + isDir, + size: isDir ? 0 : 1024, + modified: new Date('2025-01-01'), + etag: undefined, + } +} + +/** + * Creates a mock BaseClient for testing server dispatch. + */ +function createMockClient(mountPath: string): BaseClient { + const cache = new MemoryCache() + + const mockClient = { + mountPath, + cache, + sync: { + start: vi.fn(), + stop: vi.fn(), + forceSync: vi.fn().mockResolvedValue(undefined), + prioritise: vi.fn(), + waitForDrain: vi.fn().mockResolvedValue(undefined), + } as unknown as SyncWorker, + list: vi.fn(async () => []), + stat: vi.fn(async (path: string) => makeNode(path)), + read: vi.fn(), + write: vi.fn(), + mkdir: vi.fn(), + delete: vi.fn(), + rename: vi.fn(), + } + + return mockClient as unknown as BaseClient +} + +/** + * Concrete subclass of BaseServer for testing — exposes protected methods. + */ +class TestServer extends BaseServer { + async start() {} + async stop() {} + + // Expose protected methods for testing + public testResolveClient(path: string) { return this.resolveClient(path) } + public testVirtualRoot() { return this.virtualRoot() } + public testHandleStat(path: string) { return this.handleStat(path) } + public testHandleList(path: string) { return this.handleList(path) } +} + +describe('BaseServer', () => { + let server: TestServer + let homeClient: BaseClient + let storageClient: BaseClient + + beforeEach(() => { + homeClient = createMockClient('/home') + storageClient = createMockClient('/storage') + server = new TestServer([homeClient, storageClient]) + }) + + describe('resolveClient', () => { + it('should resolve paths under /home to the home client', () => { + const result = server.testResolveClient('/home/docs/file.txt') + expect(result).not.toBeNull() + expect(result!.client).toBe(homeClient) + expect(result!.remotePath).toBe('/docs/file.txt') + }) + + it('should resolve paths under /storage to the storage client', () => { + const result = server.testResolveClient('/storage/data/backup.zip') + expect(result).not.toBeNull() + expect(result!.client).toBe(storageClient) + expect(result!.remotePath).toBe('/data/backup.zip') + }) + + it('should resolve mount point itself to /', () => { + const result = server.testResolveClient('/home') + expect(result).not.toBeNull() + expect(result!.remotePath).toBe('/') + }) + + it('should return null for root path', () => { + expect(server.testResolveClient('/')).toBeNull() + }) + + it('should return null for unmatched paths', () => { + expect(server.testResolveClient('/unknown/path')).toBeNull() + }) + }) + + describe('virtualRoot', () => { + it('should return one entry per mounted client', () => { + const entries = server.testVirtualRoot() + expect(entries).toHaveLength(2) + expect(entries[0].name).toBe('home') + expect(entries[0].isDir).toBe(true) + expect(entries[1].name).toBe('storage') + expect(entries[1].isDir).toBe(true) + }) + }) + + describe('handleStat', () => { + it('should return a virtual node for root', async () => { + const node = await server.testHandleStat('/') + expect(node).not.toBeNull() + expect(node!.isDir).toBe(true) + expect(node!.path).toBe('/') + }) + + it('should return a synthetic directory node for mount-point paths', async () => { + const node = await server.testHandleStat('/home') + expect(node).not.toBeNull() + expect(node!.isDir).toBe(true) + expect(node!.name).toBe('home') + }) + + it('should return cached node if available', async () => { + const cached = makeNode('/docs/file.txt') + await homeClient.cache.set('/docs/file.txt', cached) + + const node = await server.testHandleStat('/home/docs/file.txt') + expect(node).toEqual(cached) + }) + + it('should fall through to direct stat on cache miss', async () => { + const statNode = makeNode('/docs/file.txt') + ;(homeClient.stat as ReturnType).mockResolvedValue(statNode) + + const node = await server.testHandleStat('/home/docs/file.txt') + expect(node).toEqual(statNode) + expect(homeClient.stat).toHaveBeenCalledWith('/docs/file.txt') + }) + + it('should return null for unresolvable paths', async () => { + const node = await server.testHandleStat('/unknown/path') + expect(node).toBeNull() + }) + + it('should return null if stat throws', async () => { + ;(homeClient.stat as ReturnType).mockRejectedValue(new Error('Not found')) + + const node = await server.testHandleStat('/home/nonexistent') + expect(node).toBeNull() + }) + }) + + describe('handleList', () => { + it('should return virtual root entries for /', async () => { + const entries = await server.testHandleList('/') + expect(entries).toHaveLength(2) + expect(entries.map(e => e.name)).toContain('home') + expect(entries.map(e => e.name)).toContain('storage') + }) + + it('should return cached children if available', async () => { + await homeClient.cache.set('/file1.txt', makeNode('/file1.txt')) + await homeClient.cache.set('/file2.txt', makeNode('/file2.txt')) + + const entries = await server.testHandleList('/home') + expect(entries).toHaveLength(2) + }) + + it('should fall through to direct list on cache miss', async () => { + const remoteNodes = [makeNode('/a.txt'), makeNode('/b.txt')] + ;(homeClient.list as ReturnType).mockResolvedValue(remoteNodes) + + const entries = await server.testHandleList('/home') + expect(entries).toEqual(remoteNodes) + expect(homeClient.list).toHaveBeenCalledWith('/') + }) + + it('should return empty array for unresolvable paths', async () => { + const entries = await server.testHandleList('/unknown') + expect(entries).toHaveLength(0) + }) + + it('should return empty array if list throws', async () => { + ;(homeClient.list as ReturnType).mockRejectedValue(new Error('Timeout')) + + const entries = await server.testHandleList('/home') + expect(entries).toHaveLength(0) + }) + }) +}) diff --git a/src/adapters/server/base.ts b/src/adapters/server/base.ts new file mode 100644 index 0000000..21fe8ba --- /dev/null +++ b/src/adapters/server/base.ts @@ -0,0 +1,196 @@ +import { posix } from 'node:path' +import type { GenericNode } from '../../types.js' +import type { BaseClient } from '../client/base.js' +import { logger } from '../../logger.js' + +/** Result of resolving an incoming path to a specific client. */ +export interface ResolvedPath { + client: BaseClient + remotePath: string +} + +/** + * Filenames that macOS Finder and other OS-level clients probe for + * automatically. These never exist on remote storage and should be + * silently rejected to avoid log spam and unnecessary network requests. + * + * - `._*` — AppleDouble resource fork sidecar files + * - `.DS_Store` — Finder directory metadata + * - `.Spotlight-V100`, `.Trashes`, `.fseventsd` — Spotlight / Trash / FSEvents + * - `desktop.ini`, `Thumbs.db` — Windows Explorer metadata + */ +const IGNORED_BASENAMES_RE = /^\._|^\.DS_Store$|^\.Spotlight-V100$|^\.Trashes$|^\.fseventsd$|^desktop\.ini$|^Thumbs\.db$/ + +/** Returns true if the basename of a path matches a known OS metadata probe. */ +export function isIgnoredPath(filePath: string): boolean { + const name = posix.basename(filePath) + return IGNORED_BASENAMES_RE.test(name) +} + +/** + * Abstract base class for all protocol servers (SFTP, FTP, WebDAV). + * + * Servers are pure protocol translators — they dispatch requests to the correct + * client based on `mountPath`. They know nothing about remote protocols. + */ +export abstract class BaseServer { + protected clients: BaseClient[] + + constructor(clients: BaseClient[]) { + this.clients = clients + } + + /** Starts the protocol server. */ + abstract start(): Promise + + /** Stops the protocol server. */ + abstract stop(): Promise + + /** + * Resolves an incoming virtual path to the client that owns it and the + * path relative to that client's basePath. + * + * Example: `/home/docs/file.txt` with a client mounted at `/home` + * returns `{ client, remotePath: '/docs/file.txt' }`. + * + * Returns null for the virtual root (`/`) or paths that don't match any client. + */ + protected resolveClient(incomingPath: string): ResolvedPath | null { + const normalised = posix.normalize(incomingPath) + + for (const client of this.clients) { + const mount = client.mountPath.replace(/\/+$/, '') + + // Exact match on the mount point itself → the root of that client + if (normalised === mount) { + return { client, remotePath: '/' } + } + + // Path is under this mount point + if (normalised.startsWith(mount + '/')) { + const remotePath = normalised.slice(mount.length) || '/' + return { client, remotePath } + } + } + + return null + } + + /** + * Returns synthetic GenericNode entries for the virtual root. + * One directory entry per mounted client. + */ + protected virtualRoot(): GenericNode[] { + return this.clients.map(client => { + const mountName = posix.basename(client.mountPath) + return { + path: client.mountPath, + name: mountName, + isDir: true, + size: 0, + modified: new Date(), + etag: undefined, + } + }) + } + + /** + * Handles a stat request with cache-first strategy. + * 1. Check client.cache — if hit, return immediately + * 2. If miss, prioritise the parent dir for sync and try stat directly + * 3. Never block indefinitely — return null if all else fails + */ + protected async handleStat(incomingPath: string): Promise { + const resolved = this.resolveClient(incomingPath) + + // Virtual root stat + if (!resolved) { + if (incomingPath === '/') { + return { + path: '/', + name: '/', + isDir: true, + size: 0, + modified: new Date(), + } + } + return null + } + + const { client, remotePath } = resolved + + // Silently ignore OS metadata probes (._files, .DS_Store, etc.) + if (isIgnoredPath(remotePath)) { + return null + } + + // Mount-point root — always a synthetic directory (the client's basePath root) + if (remotePath === '/') { + const mountName = posix.basename(client.mountPath) + return { + path: client.mountPath, + name: mountName, + isDir: true, + size: 0, + modified: new Date(), + } + } + + // Try cache first + const cached = await client.cache.get(remotePath) + if (cached) return cached + + // Prioritise the parent directory for sync + const parentDir = posix.dirname(remotePath) + client.sync.prioritise(parentDir) + + // Fall through to direct stat + try { + const node = await client.stat(remotePath) + await client.cache.set(remotePath, node) + return node + } catch (err) { + logger.error(`stat failed for ${incomingPath}: ${(err as Error).message}`) + return null + } + } + + /** + * Handles a list request with cache-first strategy. + * 1. Check client.cache.children — if non-empty, return immediately + * 2. If miss, prioritise the path for sync and try list directly + * 3. Never block indefinitely — return empty array if all else fails + */ + protected async handleList(incomingPath: string): Promise { + const resolved = this.resolveClient(incomingPath) + + // Virtual root listing + if (!resolved) { + if (incomingPath === '/') { + return this.virtualRoot() + } + return [] + } + + const { client, remotePath } = resolved + + // Try cache first + const cached = await client.cache.children(remotePath) + if (cached.length > 0) return cached + + // Prioritise this path for sync + client.sync.prioritise(remotePath) + + // Fall through to direct list + try { + const nodes = await client.list(remotePath) + for (const node of nodes) { + await client.cache.set(node.path, node) + } + return nodes + } catch (err) { + logger.error(`list failed for ${incomingPath}: ${(err as Error).message}`) + return [] + } + } +} diff --git a/src/adapters/server/ftp.ts b/src/adapters/server/ftp.ts new file mode 100644 index 0000000..5c204dc --- /dev/null +++ b/src/adapters/server/ftp.ts @@ -0,0 +1,301 @@ +import { posix } from 'node:path' +import { Readable, PassThrough } from 'node:stream' +import FtpSrvDefault from 'ftp-srv' +import type { FtpConnection } from 'ftp-srv' +import type { BaseClient } from '../client/base.js' +import { BaseServer, isIgnoredPath } from './base.js' +import { logger } from '../../logger.js' +import type { GenericNode } from '../../types.js' + +// ftp-srv is CJS — handle both default export shapes +const FtpSrv = (FtpSrvDefault as any).default ?? FtpSrvDefault + +/** Configuration options specific to the FTP server. */ +export interface FTPServerOptions { + port: number + pasv_url?: string + pasv_min?: number + pasv_max?: number +} + +/** + * FTP protocol server implemented via ftp-srv. + * + * Creates a virtual filesystem per connection that delegates all operations + * to BaseServer's dispatch helpers. + */ +export class FTPServer extends BaseServer { + private ftpServer: InstanceType | null = null + private options: FTPServerOptions + private username: string + private password: string + + constructor( + clients: BaseClient[], + options: FTPServerOptions, + credentials: { username: string; password: string }, + ) { + super(clients) + this.options = options + this.username = credentials.username + this.password = credentials.password + } + + async start(): Promise { + this.ftpServer = new FtpSrv({ + url: `ftp://0.0.0.0:${this.options.port}`, + pasv_url: this.options.pasv_url ?? '127.0.0.1', + pasv_min: this.options.pasv_min ?? 1024, + pasv_max: this.options.pasv_max ?? 65535, + anonymous: false, + }) + + this.ftpServer.on('login', ( + data: { connection: FtpConnection; username: string; password: string }, + resolve: (config: { fs: any }) => void, + reject: (err: Error) => void, + ) => { + if (data.username === this.username && data.password === this.password) { + logger.info(`FTP client authenticated: ${data.connection.ip}`) + const fs = new VirtualFileSystem(data.connection, this) + resolve({ fs }) + } else { + reject(new Error('Invalid credentials')) + } + }) + + this.ftpServer.on('client-error', (data: { connection: FtpConnection; error: Error }) => { + logger.error(`FTP client error (${data.connection.ip}): ${data.error.message}`) + }) + + await this.ftpServer.listen() + logger.info(`FTP server listening on port ${this.options.port}`) + } + + async stop(): Promise { + if (this.ftpServer) { + await this.ftpServer.close() + this.ftpServer = null + } + } +} + +/** + * Virtual filesystem that ftp-srv uses for file operations. + * + * Overrides every method from ftp-srv's built-in FileSystem to prevent + * real filesystem access. All operations delegate to BaseServer's dispatch helpers. + */ +class VirtualFileSystem { + /** Current working directory in the virtual filesystem. */ + cwd: string = '/' + /** Required by ftp-srv but not used — we don't touch the real filesystem. */ + readonly root: string = '/' + + private connection: FtpConnection + private server: FTPServer + + constructor(connection: FtpConnection, server: FTPServer) { + this.connection = connection + this.server = server + } + + currentDirectory(): string { + return this.cwd + } + + /** + * Resolves a potentially relative FTP path into an absolute virtual path. + */ + private resolveFtpPath(path: string = '.'): string { + if (path === '.') return this.cwd + if (posix.isAbsolute(path)) return posix.normalize(path) + return posix.normalize(posix.join(this.cwd, path)) + } + + /** + * Returns stat-like info for a single path. + * ftp-srv uses this for SIZE, MDTM, and pre-transfer checks. + */ + async get(fileName: string): Promise { + const fullPath = this.resolveFtpPath(fileName) + const node = await (this.server as any).handleStat(fullPath) + + if (!node) { + throw new Error(`Not found: ${fullPath}`) + } + + return nodeToFtpStat(node) + } + + /** Lists directory contents. Returns stat-like objects with a `.name` property. */ + async list(path: string = '.'): Promise { + const fullPath = this.resolveFtpPath(path) + const nodes = await (this.server as any).handleList(fullPath) + return nodes.map(nodeToFtpStat) + } + + /** Changes the current working directory. Returns the new path. */ + async chdir(path: string = '.'): Promise { + const fullPath = this.resolveFtpPath(path) + + // Verify the directory exists + if (fullPath === '/') { + this.cwd = '/' + return '/' + } + + const node = await (this.server as any).handleStat(fullPath) + if (!node || !node.isDir) { + throw new Error(`Not a valid directory: ${fullPath}`) + } + + this.cwd = fullPath + return this.cwd + } + + /** + * Returns a writable stream for uploading a file. + * ftp-srv pipes the incoming data into this stream. + */ + write(fileName: string, _options?: { append?: boolean; start?: number }): { stream: PassThrough; clientPath: string } { + const fullPath = this.resolveFtpPath(fileName) + + if (isIgnoredPath(fullPath)) { + throw new Error(`Blocked: OS metadata file: ${fullPath}`) + } + + const resolved = (this.server as any).resolveClient(fullPath) + + if (!resolved) { + throw new Error(`Cannot write to: ${fullPath}`) + } + + const { client, remotePath } = resolved + const passthrough = new PassThrough() + + // Pipe the incoming data to the remote client, then sync + const writePromise = client.write(remotePath, passthrough) + .then(() => client.sync.forceSync(posix.dirname(remotePath))) + .catch((err: Error) => logger.error(`FTP write error for ${fullPath}: ${err.message}`)) + + return { + stream: passthrough, + clientPath: fullPath, + } + } + + /** + * Returns a readable stream for downloading a file. + * ftp-srv pipes this stream to the client. + */ + async read(fileName: string, _options?: { start?: number }): Promise<{ stream: Readable; clientPath: string }> { + const fullPath = this.resolveFtpPath(fileName) + const resolved = (this.server as any).resolveClient(fullPath) + + if (!resolved) { + throw new Error(`Cannot read: ${fullPath}`) + } + + const { client, remotePath } = resolved + const stream = await client.read(remotePath) + + return { + stream, + clientPath: fullPath, + } + } + + /** Deletes a file or directory. */ + async delete(path: string): Promise { + const fullPath = this.resolveFtpPath(path) + const resolved = (this.server as any).resolveClient(fullPath) + + if (!resolved) { + throw new Error(`Cannot delete: ${fullPath}`) + } + + const { client, remotePath } = resolved + await client.delete(remotePath) + await client.sync.forceSync(posix.dirname(remotePath)) + } + + /** Creates a directory. Returns the new path. */ + async mkdir(path: string): Promise { + const fullPath = this.resolveFtpPath(path) + + if (isIgnoredPath(fullPath)) { + throw new Error(`Blocked: OS metadata directory: ${fullPath}`) + } + + const resolved = (this.server as any).resolveClient(fullPath) + + if (!resolved) { + throw new Error(`Cannot mkdir: ${fullPath}`) + } + + const { client, remotePath } = resolved + await client.mkdir(remotePath) + await client.sync.forceSync(posix.dirname(remotePath)) + return fullPath + } + + /** Renames/moves a file or directory. */ + async rename(from: string, to: string): Promise { + const fromPath = this.resolveFtpPath(from) + const toPath = this.resolveFtpPath(to) + + const resolvedFrom = (this.server as any).resolveClient(fromPath) + const resolvedTo = (this.server as any).resolveClient(toPath) + + if (!resolvedFrom || !resolvedTo) { + throw new Error(`Cannot rename: ${fromPath} -> ${toPath}`) + } + + if (resolvedFrom.client !== resolvedTo.client) { + throw new Error('Cannot rename across mount points') + } + + await resolvedFrom.client.rename(resolvedFrom.remotePath, resolvedTo.remotePath) + await resolvedFrom.client.sync.forceSync(posix.dirname(resolvedFrom.remotePath)) + await resolvedFrom.client.sync.forceSync(posix.dirname(resolvedTo.remotePath)) + } + + /** No-op — we don't support chmod on virtual files. */ + async chmod(_path: string, _mode: string): Promise { + // silently succeed + } + + /** Generates a unique filename for uploads. */ + getUniqueName(_fileName: string): string { + return `${Date.now()}-${Math.random().toString(36).slice(2)}` + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Shape that ftp-srv expects from stat/list results. */ +interface FtpStatResult { + name: string + size: number + mtime: Date + isDirectory: () => boolean + mode: number + uid: number + gid: number +} + +/** Converts a GenericNode to the stat-like shape ftp-srv expects. */ +function nodeToFtpStat(node: GenericNode): FtpStatResult { + return { + name: node.name, + size: node.size, + mtime: node.modified, + isDirectory: () => node.isDir, + mode: node.isDir ? 0o40755 : 0o100644, + uid: 0, + gid: 0, + } +} diff --git a/src/adapters/server/nfs.ts b/src/adapters/server/nfs.ts new file mode 100644 index 0000000..c4770c5 --- /dev/null +++ b/src/adapters/server/nfs.ts @@ -0,0 +1,48 @@ +import type { BaseClient } from '../client/base.js' +import { BaseServer } from './base.js' + +/** + * NFS protocol server — not yet implemented. + * + * NFS (Network File System) operates over ONC RPC with XDR encoding. + * A working implementation requires: + * + * 1. **Portmapper** (port 111) — maps RPC program numbers to ports. + * 2. **Mount daemon** — handles NFS mount/unmount requests and exports. + * 3. **NFS daemon** — implements the NFS v3 or v4 protocol: + * - GETATTR / SETATTR — file attributes + * - LOOKUP / READDIR / READDIRPLUS — directory traversal + * - READ / WRITE — file I/O + * - CREATE / REMOVE / RENAME / MKDIR / RMDIR — mutations + * - FSSTAT / FSINFO / PATHCONF — filesystem metadata + * + * There is no production-ready NFS server library for Node.js. + * Implementing this from scratch would require: + * - An ONC RPC framing layer over TCP/UDP + * - XDR serialisation/deserialisation for all NFS data types + * - Proper filehandle generation and management + * - NFS v3 or v4 state machine compliance + * + * For macOS/Linux file sharing, consider using WebDAV or SFTP instead — + * both are fully supported by this tool and integrate with native file managers. + * + * If NFS support is critical, a viable alternative is running a lightweight + * NFS daemon (e.g. nfs-ganesha or unfs3) and using a FUSE adapter to bridge + * our virtual filesystem to the kernel VFS layer. + */ +export class NFSServer extends BaseServer { + constructor(clients: BaseClient[]) { + super(clients) + } + + async start(): Promise { + throw new Error( + 'NFS server is not implemented. NFS requires ONC RPC with XDR encoding, ' + + 'which has no viable Node.js library. Use SFTP or WebDAV instead.' + ) + } + + async stop(): Promise { + throw new Error('Not implemented') + } +} diff --git a/src/adapters/server/sftp.ts b/src/adapters/server/sftp.ts new file mode 100644 index 0000000..28981bf --- /dev/null +++ b/src/adapters/server/sftp.ts @@ -0,0 +1,457 @@ +import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs' +import { join, dirname, posix } from 'node:path' +import { homedir } from 'node:os' +import { Readable } from 'node:stream' +import { generateKeyPairSync } from 'node:crypto' +import ssh2 from 'ssh2' +const { Server: SSHServer, utils: sshUtils } = ssh2 +type Connection = ssh2.Connection +type Session = ssh2.Session + +/** SFTP status codes from ssh2's utils namespace. */ +const SFTP_STATUS_CODE = sshUtils.sftp.STATUS_CODE +/** SFTP open mode flags from ssh2's utils namespace. */ +const SFTP_OPEN_MODE = sshUtils.sftp.OPEN_MODE +import type { BaseClient } from '../client/base.js' +import { BaseServer, isIgnoredPath } from './base.js' +import { logger } from '../../logger.js' + +const DEFAULT_CONFIG_DIR = join(homedir(), '.config', 'sftp-proxy') +const DEFAULT_HOST_KEY_PATH = join(DEFAULT_CONFIG_DIR, 'host.key') + +/** + * Generates an RSA host key and saves it to disk, or loads it if it already exists. + * @param keyPath - Path to the host key file. Defaults to ~/.config/sftp-proxy/host.key. + */ +function getOrCreateHostKey(keyPath: string = DEFAULT_HOST_KEY_PATH): string { + if (existsSync(keyPath)) { + return readFileSync(keyPath, 'utf-8') + } + + mkdirSync(dirname(keyPath), { recursive: true }) + + const { privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'pkcs1', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, + }) + + writeFileSync(keyPath, privateKey, { mode: 0o600 }) + logger.info(`Generated new host key at ${keyPath}`) + return privateKey +} + +/** Tracks open file handles for read/write operations. */ +interface OpenHandle { + path: string + flags: number + /** Buffer accumulating write data (for write operations). */ + writeBuffer?: Buffer[] + /** Readable stream for read operations. */ + readStream?: Readable + /** Buffer of data read from the stream. */ + readBuffer?: Buffer + /** Whether we've fully consumed the read stream into readBuffer. */ + readComplete?: boolean +} + +/** + * SFTP protocol server implemented via ssh2. + * + * Auto-generates an RSA host key on first run. + * Authenticates against the credentials from config. + * Translates all SFTP operations into calls to the base server dispatch helpers. + */ +export class SFTPServer extends BaseServer { + private server: InstanceType | null = null + private port: number + private username: string + private password: string + private hostKeyPath: string + + constructor( + clients: BaseClient[], + port: number, + credentials: { username: string; password: string }, + hostKeyPath?: string, + ) { + super(clients) + this.port = port + this.username = credentials.username + this.password = credentials.password + this.hostKeyPath = hostKeyPath ?? DEFAULT_HOST_KEY_PATH + } + + async start(): Promise { + const hostKey = getOrCreateHostKey(this.hostKeyPath) + + this.server = new SSHServer({ hostKeys: [hostKey] }, (client) => { + this.handleConnection(client) + }) + + return new Promise((resolve, reject) => { + this.server!.on('error', reject) + this.server!.listen(this.port, '0.0.0.0', () => { + logger.info(`SFTP server listening on port ${this.port}`) + resolve() + }) + }) + } + + async stop(): Promise { + return new Promise((resolve) => { + if (this.server) { + this.server.close(() => resolve()) + } else { + resolve() + } + }) + } + + /** Handles a new SSH connection — authenticates and sets up the SFTP session. */ + private handleConnection(client: Connection): void { + logger.info('New SSH connection') + + client.on('authentication', (ctx) => { + if ( + ctx.method === 'password' && + ctx.username === this.username && + ctx.password === this.password + ) { + ctx.accept() + } else { + ctx.reject(['password']) + } + }) + + client.on('ready', () => { + logger.info('Client authenticated') + + client.on('session', (accept) => { + const session: Session = accept() + session.on('sftp', (accept) => { + const sftpStream = accept() + this.handleSFTPSession(sftpStream) + }) + }) + }) + + client.on('error', (err) => { + logger.error(`SSH client error: ${err.message}`) + }) + } + + /** + * Handles all SFTP operations on a session. + * Maps SFTP protocol operations to GenericRequest dispatch. + */ + private handleSFTPSession(sftp: any): void { + let handleCounter = 0 + const openHandles = new Map() + + /** Creates a new handle ID and stores metadata. */ + const allocHandle = (info: OpenHandle): Buffer => { + const id = handleCounter++ + openHandles.set(id, info) + const buf = Buffer.alloc(4) + buf.writeUInt32BE(id) + return buf + } + + /** Looks up a handle from its buffer representation. */ + const getHandle = (handleBuf: Buffer): OpenHandle | undefined => { + const id = handleBuf.readUInt32BE(0) + return openHandles.get(id) + } + + /** Closes and removes a handle. */ + const closeHandle = (handleBuf: Buffer): boolean => { + const id = handleBuf.readUInt32BE(0) + return openHandles.delete(id) + } + + /** Converts a GenericNode to the attrs format expected by SFTP. */ + const nodeToAttrs = (node: { isDir: boolean; size: number; modified: Date }) => ({ + mode: node.isDir ? 0o40755 : 0o100644, + uid: 0, + gid: 0, + size: node.size, + atime: Math.floor(node.modified.getTime() / 1000), + mtime: Math.floor(node.modified.getTime() / 1000), + }) + + // --- OPEN --- + sftp.on('OPEN', async (reqid: number, filename: string, flags: number) => { + try { + // Block writes for OS metadata files (.DS_Store, ._ files, etc.) + if ((flags & SFTP_OPEN_MODE.WRITE) && isIgnoredPath(filename)) { + return sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED) + } + + const resolved = this.resolveClient(filename) + if (!resolved) { + return sftp.status(reqid, SFTP_STATUS_CODE.NO_SUCH_FILE) + } + + const handle = allocHandle({ path: filename, flags }) + + // If reading, pre-fetch the entire file content into a buffer + if (flags & SFTP_OPEN_MODE.READ) { + const stream = await resolved.client.read(resolved.remotePath) + const chunks: Buffer[] = [] + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + const handleInfo = openHandles.get(handle.readUInt32BE(0))! + handleInfo.readBuffer = Buffer.concat(chunks) + handleInfo.readComplete = true + } + + // If writing, initialise the write buffer + if (flags & SFTP_OPEN_MODE.WRITE) { + const handleInfo = openHandles.get(handle.readUInt32BE(0))! + handleInfo.writeBuffer = [] + } + + sftp.handle(reqid, handle) + } catch (err) { + logger.error(`OPEN error for ${filename}: ${(err as Error).message}`) + sftp.status(reqid, SFTP_STATUS_CODE.FAILURE) + } + }) + + // --- READ --- + sftp.on('READ', (reqid: number, handle: Buffer, offset: number, length: number) => { + const info = getHandle(handle) + if (!info || !info.readBuffer) { + return sftp.status(reqid, SFTP_STATUS_CODE.FAILURE) + } + + if (offset >= info.readBuffer.length) { + return sftp.status(reqid, SFTP_STATUS_CODE.EOF) + } + + const end = Math.min(offset + length, info.readBuffer.length) + const data = info.readBuffer.subarray(offset, end) + sftp.data(reqid, data) + }) + + // --- WRITE --- + sftp.on('WRITE', (reqid: number, handle: Buffer, offset: number, data: Buffer) => { + const info = getHandle(handle) + if (!info || !info.writeBuffer) { + return sftp.status(reqid, SFTP_STATUS_CODE.FAILURE) + } + + info.writeBuffer.push(Buffer.from(data)) + sftp.status(reqid, SFTP_STATUS_CODE.OK) + }) + + // --- CLOSE --- + sftp.on('CLOSE', async (reqid: number, handle: Buffer) => { + const info = getHandle(handle) + if (!info) { + return sftp.status(reqid, SFTP_STATUS_CODE.FAILURE) + } + + try { + // If there's pending write data, flush it to the remote + if (info.writeBuffer && info.writeBuffer.length > 0) { + const resolved = this.resolveClient(info.path) + if (resolved) { + const buf = Buffer.concat(info.writeBuffer) + const stream = Readable.from(buf) + await resolved.client.write(resolved.remotePath, stream) + await resolved.client.sync.forceSync(posix.dirname(resolved.remotePath)) + } + } + + closeHandle(handle) + sftp.status(reqid, SFTP_STATUS_CODE.OK) + } catch (err) { + logger.error(`CLOSE error for ${info.path}: ${(err as Error).message}`) + sftp.status(reqid, SFTP_STATUS_CODE.FAILURE) + } + }) + + // --- OPENDIR --- + sftp.on('OPENDIR', async (reqid: number, path: string) => { + try { + const entries = await this.handleList(path) + const handle = allocHandle({ path, flags: 0 }) + const handleInfo = openHandles.get(handle.readUInt32BE(0))! + ;(handleInfo as any).dirEntries = entries + ;(handleInfo as any).dirRead = false + sftp.handle(reqid, handle) + } catch (err) { + logger.error(`OPENDIR error for ${path}: ${(err as Error).message}`) + sftp.status(reqid, SFTP_STATUS_CODE.FAILURE) + } + }) + + // --- READDIR --- + sftp.on('READDIR', (reqid: number, handle: Buffer) => { + const info = getHandle(handle) as any + if (!info || !info.dirEntries) { + return sftp.status(reqid, SFTP_STATUS_CODE.FAILURE) + } + + // First call returns all entries; subsequent calls return EOF + if (info.dirRead) { + return sftp.status(reqid, SFTP_STATUS_CODE.EOF) + } + + info.dirRead = true + const entries = (info.dirEntries as import('../../types.js').GenericNode[]) + + if (entries.length === 0) { + return sftp.status(reqid, SFTP_STATUS_CODE.EOF) + } + + const names = entries.map(node => ({ + filename: node.name, + longname: formatLongname(node), + attrs: nodeToAttrs(node), + })) + + sftp.name(reqid, names) + }) + + // --- STAT / LSTAT / FSTAT --- + const handleStatRequest = async (reqid: number, path: string) => { + try { + const node = await this.handleStat(path) + if (!node) { + return sftp.status(reqid, SFTP_STATUS_CODE.NO_SUCH_FILE) + } + sftp.attrs(reqid, nodeToAttrs(node)) + } catch (err) { + logger.error(`STAT error for ${path}: ${(err as Error).message}`) + sftp.status(reqid, SFTP_STATUS_CODE.FAILURE) + } + } + + sftp.on('STAT', (reqid: number, path: string) => handleStatRequest(reqid, path)) + sftp.on('LSTAT', (reqid: number, path: string) => handleStatRequest(reqid, path)) + + sftp.on('FSTAT', (reqid: number, handle: Buffer) => { + const info = getHandle(handle) + if (!info) { + return sftp.status(reqid, SFTP_STATUS_CODE.FAILURE) + } + handleStatRequest(reqid, info.path) + }) + + // --- MKDIR --- + sftp.on('MKDIR', async (reqid: number, path: string) => { + try { + if (isIgnoredPath(path)) { + return sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED) + } + const resolved = this.resolveClient(path) + if (!resolved) { + return sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED) + } + await resolved.client.mkdir(resolved.remotePath) + await resolved.client.sync.forceSync(posix.dirname(resolved.remotePath)) + sftp.status(reqid, SFTP_STATUS_CODE.OK) + } catch (err) { + logger.error(`MKDIR error for ${path}: ${(err as Error).message}`) + sftp.status(reqid, SFTP_STATUS_CODE.FAILURE) + } + }) + + // --- RMDIR --- + sftp.on('RMDIR', async (reqid: number, path: string) => { + try { + const resolved = this.resolveClient(path) + if (!resolved) { + return sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED) + } + await resolved.client.delete(resolved.remotePath) + await resolved.client.sync.forceSync(posix.dirname(resolved.remotePath)) + sftp.status(reqid, SFTP_STATUS_CODE.OK) + } catch (err) { + logger.error(`RMDIR error for ${path}: ${(err as Error).message}`) + sftp.status(reqid, SFTP_STATUS_CODE.FAILURE) + } + }) + + // --- REMOVE --- + sftp.on('REMOVE', async (reqid: number, path: string) => { + try { + const resolved = this.resolveClient(path) + if (!resolved) { + return sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED) + } + await resolved.client.delete(resolved.remotePath) + await resolved.client.sync.forceSync(posix.dirname(resolved.remotePath)) + sftp.status(reqid, SFTP_STATUS_CODE.OK) + } catch (err) { + logger.error(`REMOVE error for ${path}: ${(err as Error).message}`) + sftp.status(reqid, SFTP_STATUS_CODE.FAILURE) + } + }) + + // --- RENAME --- + sftp.on('RENAME', async (reqid: number, oldPath: string, newPath: string) => { + try { + const resolvedOld = this.resolveClient(oldPath) + const resolvedNew = this.resolveClient(newPath) + + if (!resolvedOld || !resolvedNew) { + return sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED) + } + + // Both paths must resolve to the same client + if (resolvedOld.client !== resolvedNew.client) { + return sftp.status(reqid, SFTP_STATUS_CODE.PERMISSION_DENIED) + } + + await resolvedOld.client.rename(resolvedOld.remotePath, resolvedNew.remotePath) + await resolvedOld.client.sync.forceSync(posix.dirname(resolvedOld.remotePath)) + await resolvedOld.client.sync.forceSync(posix.dirname(resolvedNew.remotePath)) + sftp.status(reqid, SFTP_STATUS_CODE.OK) + } catch (err) { + logger.error(`RENAME error ${oldPath} -> ${newPath}: ${(err as Error).message}`) + sftp.status(reqid, SFTP_STATUS_CODE.FAILURE) + } + }) + + // --- REALPATH --- + sftp.on('REALPATH', (reqid: number, path: string) => { + const normalised = posix.resolve('/', path) + sftp.name(reqid, [{ + filename: normalised, + longname: normalised, + attrs: { + mode: 0o40755, + uid: 0, + gid: 0, + size: 0, + atime: Math.floor(Date.now() / 1000), + mtime: Math.floor(Date.now() / 1000), + }, + }]) + }) + + // --- SETSTAT / FSETSTAT (no-op, accept gracefully) --- + sftp.on('SETSTAT', (reqid: number) => { + sftp.status(reqid, SFTP_STATUS_CODE.OK) + }) + sftp.on('FSETSTAT', (reqid: number) => { + sftp.status(reqid, SFTP_STATUS_CODE.OK) + }) + } +} + +/** + * Formats a GenericNode as a UNIX-style long filename string + * (similar to `ls -l` output) for READDIR responses. + */ +function formatLongname(node: import('../../types.js').GenericNode): string { + const perms = node.isDir ? 'drwxr-xr-x' : '-rw-r--r--' + const size = String(node.size).padStart(13) + const date = node.modified.toISOString().slice(0, 10) + return `${perms} 1 owner group ${size} ${date} ${node.name}` +} diff --git a/src/adapters/server/smb.ts b/src/adapters/server/smb.ts new file mode 100644 index 0000000..188dc16 --- /dev/null +++ b/src/adapters/server/smb.ts @@ -0,0 +1,54 @@ +import type { BaseClient } from '../client/base.js' +import { BaseServer } from './base.js' + +/** + * SMB protocol server — not yet implemented. + * + * SMB (Server Message Block) is the native file-sharing protocol for Windows. + * A working implementation requires the full SMB2/3 protocol stack: + * + * 1. **Negotiate** — protocol version and capability negotiation + * 2. **Session Setup** — NTLM/Kerberos authentication + * 3. **Tree Connect** — share mounting + * 4. **File operations**: + * - CREATE / CLOSE — open/close file handles + * - READ / WRITE — file I/O with credit-based flow control + * - QUERY_DIRECTORY — directory listing + * - QUERY_INFO / SET_INFO — stat and attribute management + * - CHANGE_NOTIFY — filesystem watch notifications + * 5. **Oplock/Lease management** — caching and lock semantics + * + * There is no production-ready SMB server library for Node.js. + * Implementing this from scratch would require: + * - SMB2/3 packet framing and parsing over TCP (port 445) + * - NTLM or SPNEGO/Kerberos authentication + * - Credit-based flow control for SMB3 + * - Compound request handling + * - Oplock/lease state machines + * + * For Windows file sharing, consider: + * - **WebDAV** — Windows Explorer supports `\\server@port\DavWWWRoot` paths + * - **SFTP** — via WinSCP, FileZilla, or Windows OpenSSH + * - Running Samba natively and bridging with a FUSE adapter + * + * If SMB support is critical, the recommended approach is to run Samba + * alongside this tool and use a FUSE mount as the bridge between our + * virtual filesystem and Samba's VFS layer. + */ +export class SMBServer extends BaseServer { + constructor(clients: BaseClient[]) { + super(clients) + } + + async start(): Promise { + throw new Error( + 'SMB server is not implemented. SMB requires the full SMB2/3 protocol stack ' + + 'including NTLM authentication, which has no viable Node.js library. ' + + 'Use WebDAV (Windows Explorer supports it natively) or SFTP instead.' + ) + } + + async stop(): Promise { + throw new Error('Not implemented') + } +} diff --git a/src/adapters/server/webdav.ts b/src/adapters/server/webdav.ts new file mode 100644 index 0000000..c2f1a70 --- /dev/null +++ b/src/adapters/server/webdav.ts @@ -0,0 +1,497 @@ +import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http' +import { posix } from 'node:path' +import { Readable } from 'node:stream' +import type { BaseClient } from '../client/base.js' +import { BaseServer, isIgnoredPath } from './base.js' +import { logger } from '../../logger.js' +import type { GenericNode } from '../../types.js' + +/** + * WebDAV protocol server implemented over Node's built-in http module. + * + * Supports the core WebDAV methods required by macOS Finder, Windows Explorer, + * and Linux GVFS/davfs2: + * OPTIONS, PROPFIND, GET, HEAD, PUT, DELETE, MKCOL, MOVE, LOCK, UNLOCK + * + * Authentication uses HTTP Basic Auth against config credentials. + */ +export class WebDAVServer extends BaseServer { + private httpServer: Server | null = null + private port: number + private username: string + private password: string + + constructor( + clients: BaseClient[], + port: number, + credentials: { username: string; password: string }, + ) { + super(clients) + this.port = port + this.username = credentials.username + this.password = credentials.password + } + + async start(): Promise { + this.httpServer = createServer((req, res) => { + this.handleRequest(req, res).catch((err) => { + logger.error(`WebDAV unhandled error: ${(err as Error).message}`) + if (!res.headersSent) { + res.writeHead(500) + res.end() + } + }) + }) + + return new Promise((resolve, reject) => { + this.httpServer!.on('error', reject) + this.httpServer!.listen(this.port, '0.0.0.0', () => { + logger.info(`WebDAV server listening on port ${this.port}`) + resolve() + }) + }) + } + + async stop(): Promise { + return new Promise((resolve) => { + if (this.httpServer) { + this.httpServer.close(() => resolve()) + } else { + resolve() + } + }) + } + + /** Top-level request router. Authenticates first, then dispatches by method. */ + private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + // CORS preflight and OPTIONS don't require auth in some clients + if (req.method === 'OPTIONS') { + return this.handleOptions(req, res) + } + + if (!this.authenticate(req)) { + res.writeHead(401, { + 'WWW-Authenticate': 'Basic realm="sftp-proxy"', + 'Content-Type': 'text/plain', + }) + res.end('Authentication required') + return + } + + const method = req.method?.toUpperCase() + + switch (method) { + case 'PROPFIND': return this.handlePropfind(req, res) + case 'GET': return this.handleGet(req, res) + case 'HEAD': return this.handleHead(req, res) + case 'PUT': return this.handlePut(req, res) + case 'DELETE': return this.handleDelete(req, res) + case 'MKCOL': return this.handleMkcol(req, res) + case 'MOVE': return this.handleMove(req, res) + case 'LOCK': return this.handleLock(req, res) + case 'UNLOCK': return this.handleUnlock(req, res) + default: + res.writeHead(405, { Allow: ALLOWED_METHODS }) + res.end() + } + } + + /** Validates HTTP Basic Auth credentials. */ + private authenticate(req: IncomingMessage): boolean { + const authHeader = req.headers.authorization + if (!authHeader || !authHeader.startsWith('Basic ')) return false + + const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8') + const [username, password] = decoded.split(':') + return username === this.username && password === this.password + } + + // --------------------------------------------------------------------------- + // Method handlers + // --------------------------------------------------------------------------- + + private handleOptions(_req: IncomingMessage, res: ServerResponse): void { + res.writeHead(200, { + Allow: ALLOWED_METHODS, + DAV: '1, 2', + 'Content-Length': '0', + }) + res.end() + } + + private async handlePropfind(req: IncomingMessage, res: ServerResponse): Promise { + const path = decodePath(req.url ?? '/') + const depth = req.headers['depth'] ?? '1' + + // We don't support Depth: infinity — return 403 per spec + if (depth === 'infinity') { + res.writeHead(403, { 'Content-Type': 'text/plain' }) + res.end('Depth: infinity is not supported') + return + } + + const isRoot = path === '/' + const resolved = this.resolveClient(path) + + // Build the list of entries to include in the multistatus response + const entries: Array<{ node: GenericNode; href: string }> = [] + + if (isRoot) { + // Stat for root itself + entries.push({ + node: { path: '/', name: '/', isDir: true, size: 0, modified: new Date() }, + href: '/', + }) + + // Depth: 1 — include virtual mount directories + if (depth === '1') { + for (const node of this.virtualRoot()) { + entries.push({ node, href: ensureTrailingSlash(node.path) }) + } + } + } else if (resolved) { + const { client, remotePath } = resolved + + // Stat the resource itself + const stat = await this.handleStat(path) + if (!stat) { + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('Not Found') + return + } + + entries.push({ + node: stat, + href: stat.isDir ? ensureTrailingSlash(path) : path, + }) + + // Depth: 1 — include children + if (depth === '1' && stat.isDir) { + const children = await this.handleList(path) + for (const child of children) { + const childPath = posix.join(path, child.name) + entries.push({ + node: child, + href: child.isDir ? ensureTrailingSlash(childPath) : childPath, + }) + } + } + } else { + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('Not Found') + return + } + + const xml = buildMultistatusXml(entries) + res.writeHead(207, { + 'Content-Type': 'application/xml; charset=utf-8', + 'Content-Length': Buffer.byteLength(xml), + }) + res.end(xml) + } + + private async handleGet(req: IncomingMessage, res: ServerResponse): Promise { + const path = decodePath(req.url ?? '/') + const resolved = this.resolveClient(path) + + if (!resolved) { + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('Not Found') + return + } + + const { client, remotePath } = resolved + + try { + const stat = await this.handleStat(path) + if (!stat) { + res.writeHead(404) + res.end('Not Found') + return + } + if (stat.isDir) { + res.writeHead(405, { 'Content-Type': 'text/plain' }) + res.end('Cannot GET a directory. Use PROPFIND.') + return + } + + const stream = await client.read(remotePath) + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Last-Modified': stat.modified.toUTCString(), + ...(stat.etag ? { ETag: `"${stat.etag}"` } : {}), + }) + stream.pipe(res) + } catch (err) { + logger.error(`WebDAV GET error for ${path}: ${(err as Error).message}`) + if (!res.headersSent) { + res.writeHead(500) + res.end() + } + } + } + + private async handleHead(req: IncomingMessage, res: ServerResponse): Promise { + const path = decodePath(req.url ?? '/') + const stat = await this.handleStat(path) + + if (!stat) { + res.writeHead(404) + res.end() + return + } + + res.writeHead(200, { + 'Content-Type': stat.isDir ? 'httpd/unix-directory' : 'application/octet-stream', + 'Content-Length': stat.size, + 'Last-Modified': stat.modified.toUTCString(), + ...(stat.etag ? { ETag: `"${stat.etag}"` } : {}), + }) + res.end() + } + + private async handlePut(req: IncomingMessage, res: ServerResponse): Promise { + const path = decodePath(req.url ?? '/') + + if (isIgnoredPath(path)) { + req.resume() + res.writeHead(403, { 'Content-Type': 'text/plain' }) + res.end('Blocked: OS metadata file') + return + } + + const resolved = this.resolveClient(path) + + if (!resolved) { + res.writeHead(403, { 'Content-Type': 'text/plain' }) + res.end('Cannot write to virtual root') + return + } + + const { client, remotePath } = resolved + + try { + await client.write(remotePath, req as unknown as Readable) + await client.sync.forceSync(posix.dirname(remotePath)) + res.writeHead(201) + res.end() + } catch (err) { + logger.error(`WebDAV PUT error for ${path}: ${(err as Error).message}`) + res.writeHead(500) + res.end() + } + } + + private async handleDelete(req: IncomingMessage, res: ServerResponse): Promise { + const path = decodePath(req.url ?? '/') + const resolved = this.resolveClient(path) + + if (!resolved) { + res.writeHead(403, { 'Content-Type': 'text/plain' }) + res.end('Cannot delete from virtual root') + return + } + + const { client, remotePath } = resolved + + try { + await client.delete(remotePath) + await client.sync.forceSync(posix.dirname(remotePath)) + res.writeHead(204) + res.end() + } catch (err) { + logger.error(`WebDAV DELETE error for ${path}: ${(err as Error).message}`) + res.writeHead(500) + res.end() + } + } + + private async handleMkcol(req: IncomingMessage, res: ServerResponse): Promise { + const path = decodePath(req.url ?? '/') + + if (isIgnoredPath(path)) { + res.writeHead(403, { 'Content-Type': 'text/plain' }) + res.end('Blocked: OS metadata directory') + return + } + + const resolved = this.resolveClient(path) + + if (!resolved) { + res.writeHead(403, { 'Content-Type': 'text/plain' }) + res.end('Cannot create directory in virtual root') + return + } + + const { client, remotePath } = resolved + + try { + await client.mkdir(remotePath) + await client.sync.forceSync(posix.dirname(remotePath)) + res.writeHead(201) + res.end() + } catch (err) { + logger.error(`WebDAV MKCOL error for ${path}: ${(err as Error).message}`) + res.writeHead(500) + res.end() + } + } + + private async handleMove(req: IncomingMessage, res: ServerResponse): Promise { + const fromPath = decodePath(req.url ?? '/') + const destinationHeader = req.headers['destination'] as string | undefined + + if (!destinationHeader) { + res.writeHead(400, { 'Content-Type': 'text/plain' }) + res.end('Missing Destination header') + return + } + + // Destination is a full URI — extract just the path + let toPath: string + try { + const url = new URL(destinationHeader, `http://${req.headers.host}`) + toPath = decodePath(url.pathname) + } catch { + toPath = decodePath(destinationHeader) + } + + const resolvedFrom = this.resolveClient(fromPath) + const resolvedTo = this.resolveClient(toPath) + + if (!resolvedFrom || !resolvedTo) { + res.writeHead(403, { 'Content-Type': 'text/plain' }) + res.end('Cannot move across virtual root boundaries') + return + } + + if (resolvedFrom.client !== resolvedTo.client) { + res.writeHead(403, { 'Content-Type': 'text/plain' }) + res.end('Cannot move across different mount points') + return + } + + try { + await resolvedFrom.client.rename(resolvedFrom.remotePath, resolvedTo.remotePath) + await resolvedFrom.client.sync.forceSync(posix.dirname(resolvedFrom.remotePath)) + await resolvedFrom.client.sync.forceSync(posix.dirname(resolvedTo.remotePath)) + res.writeHead(201) + res.end() + } catch (err) { + logger.error(`WebDAV MOVE error ${fromPath} -> ${toPath}: ${(err as Error).message}`) + res.writeHead(500) + res.end() + } + } + + /** + * Handles LOCK requests with a fake lock token. + * macOS Finder requires LOCK support for write operations. + * We always succeed — this proxy doesn't enforce real locking. + */ + private handleLock(req: IncomingMessage, res: ServerResponse): void { + const path = decodePath(req.url ?? '/') + const token = `opaquelocktoken:${Date.now()}-${Math.random().toString(36).slice(2)}` + + const xml = ` + + + + + + infinity + Second-3600 + ${token} + ${escapeXml(path)} + + +` + + res.writeHead(200, { + 'Content-Type': 'application/xml; charset=utf-8', + 'Lock-Token': `<${token}>`, + 'Content-Length': Buffer.byteLength(xml), + }) + res.end(xml) + } + + /** UNLOCK always succeeds — no real locks are held. */ + private handleUnlock(_req: IncomingMessage, res: ServerResponse): void { + res.writeHead(204) + res.end() + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const ALLOWED_METHODS = 'OPTIONS, PROPFIND, GET, HEAD, PUT, DELETE, MKCOL, MOVE, LOCK, UNLOCK' + +/** Decodes a URI-encoded request path and normalises it. */ +function decodePath(raw: string): string { + try { + const decoded = decodeURIComponent(raw.split('?')[0]) + return posix.normalize(decoded) || '/' + } catch { + return posix.normalize(raw.split('?')[0]) || '/' + } +} + +/** Ensures a path ends with `/` (for directory hrefs). */ +function ensureTrailingSlash(path: string): string { + return path.endsWith('/') ? path : path + '/' +} + +/** Escapes special XML characters. */ +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +/** + * Builds a WebDAV `207 Multi-Status` XML response from an array of entries. + * Each entry contains a GenericNode and its absolute href. + */ +function buildMultistatusXml(entries: Array<{ node: GenericNode; href: string }>): string { + const responses = entries.map(({ node, href }) => { + const resourceType = node.isDir + ? '' + : '' + + const contentLength = node.isDir + ? '' + : `${node.size}` + + const contentType = node.isDir + ? '' + : 'application/octet-stream' + + const etag = node.etag + ? `"${escapeXml(node.etag)}"` + : '' + + return ` + ${escapeXml(encodeURI(href))} + + + ${resourceType} + ${escapeXml(node.name)} + ${contentLength} + ${contentType} + ${node.modified.toUTCString()} + ${etag} + + HTTP/1.1 200 OK + + ` + }) + + return ` + +${responses.join('\n')} +` +} diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..4fa2509 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,56 @@ +import { createLogger, format, transports } from 'winston' +import Transport from 'winston-transport' +import type { SqliteCache } from './adapters/cache/sqlite.js' + +/** + * Custom winston transport that writes log entries to a SqliteCache instance. + */ +export class SqliteTransport extends Transport { + private cache: SqliteCache + + constructor(cache: SqliteCache, opts?: Transport.TransportStreamOptions) { + super(opts) + this.cache = cache + } + + log(info: { level: string; message: string }, callback: () => void): void { + setImmediate(() => this.emit('logged', info)) + this.cache.writeLog(info.level, info.message) + callback() + } +} + +/** + * Global logger instance. Starts with console transport only. + * The SQLite transport is added later once a SqliteCache is available. + */ +export const logger = createLogger({ + level: 'info', + format: format.combine( + format.timestamp(), + format.printf(({ timestamp, level, message }) => { + return `${timestamp as string} [${level}] ${message as string}` + }), + ), + transports: [ + new transports.Console(), + ], +}) + +/** + * Attaches the SQLite transport to the global logger. + * Called once during startup after the SqliteCache is created. + */ +export function attachSqliteTransport(cache: SqliteCache): void { + logger.add(new SqliteTransport(cache)) +} + +/** + * Removes the console transport from the logger. + * Called when the process is running as a background service. + */ +export function removeConsoleTransport(): void { + logger.transports + .filter(t => t instanceof transports.Console) + .forEach(t => logger.remove(t)) +} diff --git a/src/service/launchd.ts b/src/service/launchd.ts new file mode 100644 index 0000000..d0f8e76 --- /dev/null +++ b/src/service/launchd.ts @@ -0,0 +1,121 @@ +import { writeFileSync, unlinkSync, existsSync, mkdirSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { homedir } from 'node:os' +import { execSync } from 'node:child_process' +import { logger } from '../logger.js' + +const LABEL = 'com.sftp-proxy' +const PLIST_PATH = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`) +const LOG_DIR = join(homedir(), '.config', 'sftp-proxy') + +/** + * Resolves the absolute paths needed to launch sftp-proxy. + * All paths are resolved at install time so the plist is self-contained + * regardless of what working directory launchd starts from. + */ +function resolveLaunchPaths(): { projectDir: string; tsxBin: string; entryScript: string } { + const projectDir = resolve(process.cwd()) + const tsxBin = resolve(projectDir, 'node_modules', '.bin', 'tsx') + const entryScript = resolve(projectDir, 'bin', 'sftp-proxy.ts') + + if (!existsSync(tsxBin)) { + throw new Error(`tsx not found at ${tsxBin} — run npm install first`) + } + if (!existsSync(entryScript)) { + throw new Error(`Entry script not found at ${entryScript}`) + } + + return { projectDir, tsxBin, entryScript } +} + +/** + * Generates the launchd plist XML content. + * Uses absolute paths resolved at install time and sets WorkingDirectory + * so Node can find node_modules and the config file. + */ +function generatePlist(): string { + const { projectDir, tsxBin, entryScript } = resolveLaunchPaths() + + return ` + + + + Label + ${LABEL} + ProgramArguments + + ${tsxBin} + ${entryScript} + start + + WorkingDirectory + ${projectDir} + RunAtLoad + + KeepAlive + + StandardOutPath + ${join(LOG_DIR, 'stdout.log')} + StandardErrorPath + ${join(LOG_DIR, 'stderr.log')} + EnvironmentVariables + + PATH + /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin + + +` +} + +/** + * Installs the sftp-proxy launchd service. + * Creates the plist, ensures directories exist, and loads via `launchctl`. + */ +export function install(): void { + mkdirSync(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true }) + mkdirSync(LOG_DIR, { recursive: true }) + + const plist = generatePlist() + writeFileSync(PLIST_PATH, plist, { encoding: 'utf-8' }) + logger.info(`Wrote plist to ${PLIST_PATH}`) + + // Unload first in case a previous version is still loaded + try { execSync(`launchctl unload ${PLIST_PATH} 2>/dev/null`) } catch { /* ignore */ } + + try { + execSync(`launchctl load ${PLIST_PATH}`, { stdio: 'inherit' }) + logger.info('launchd service loaded — sftp-proxy will start now and on every login') + } catch (err) { + logger.error(`Failed to load launchd service: ${(err as Error).message}`) + } +} + +/** + * Uninstalls the sftp-proxy launchd service. + * Unloads via `launchctl` and removes the plist file. + */ +export function uninstall(): void { + try { + execSync(`launchctl unload ${PLIST_PATH}`, { stdio: 'inherit' }) + logger.info('launchd service unloaded') + } catch { + // may not be loaded + } + + if (existsSync(PLIST_PATH)) { + unlinkSync(PLIST_PATH) + logger.info(`Removed plist at ${PLIST_PATH}`) + } +} + +/** + * Returns the current status of the sftp-proxy launchd service. + */ +export function status(): string { + try { + const output = execSync(`launchctl list | grep ${LABEL}`, { encoding: 'utf-8' }) + return output.trim() || 'Not running' + } catch { + return 'Not installed' + } +} diff --git a/src/service/systemd.ts b/src/service/systemd.ts new file mode 100644 index 0000000..0fdbcc0 --- /dev/null +++ b/src/service/systemd.ts @@ -0,0 +1,101 @@ +import { writeFileSync, unlinkSync, existsSync, mkdirSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { homedir } from 'node:os' +import { execSync } from 'node:child_process' +import { logger } from '../logger.js' + +const UNIT_NAME = 'sftp-proxy' +const UNIT_PATH = join(homedir(), '.config', 'systemd', 'user', `${UNIT_NAME}.service`) + +/** + * Finds the path to the sftp-proxy binary. + */ +function getBinaryPath(): string { + try { + return execSync('which sftp-proxy', { encoding: 'utf-8' }).trim() + } catch { + return join(process.cwd(), 'node_modules', '.bin', 'tsx') + ' ' + join(process.cwd(), 'bin', 'sftp-proxy.ts') + } +} + +/** + * Generates the systemd user unit file content. + */ +function generateUnit(): string { + const bin = getBinaryPath() + + return `[Unit] +Description=SFTP Proxy — local protocol servers for remote storage +After=network.target + +[Service] +ExecStart=${bin} start +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target` +} + +/** + * Installs the sftp-proxy systemd user service. + * Creates the unit file and enables+starts via `systemctl --user`. + */ +export function install(): void { + mkdirSync(dirname(UNIT_PATH), { recursive: true }) + + const unit = generateUnit() + writeFileSync(UNIT_PATH, unit, { encoding: 'utf-8' }) + logger.info(`Wrote unit file to ${UNIT_PATH}`) + + try { + execSync(`systemctl --user daemon-reload`, { stdio: 'inherit' }) + execSync(`systemctl --user enable ${UNIT_NAME}`, { stdio: 'inherit' }) + execSync(`systemctl --user start ${UNIT_NAME}`, { stdio: 'inherit' }) + logger.info('systemd user service installed and started') + } catch (err) { + logger.error(`Failed to install systemd service: ${(err as Error).message}`) + } +} + +/** + * Uninstalls the sftp-proxy systemd user service. + * Stops, disables, and removes the unit file. + */ +export function uninstall(): void { + try { + execSync(`systemctl --user stop ${UNIT_NAME}`, { stdio: 'inherit' }) + execSync(`systemctl --user disable ${UNIT_NAME}`, { stdio: 'inherit' }) + logger.info('systemd user service stopped and disabled') + } catch { + // may not be active + } + + if (existsSync(UNIT_PATH)) { + unlinkSync(UNIT_PATH) + logger.info(`Removed unit file at ${UNIT_PATH}`) + } + + try { + execSync(`systemctl --user daemon-reload`, { stdio: 'inherit' }) + } catch { + // best effort + } +} + +/** + * Returns the current status of the sftp-proxy systemd user service. + */ +export function status(): string { + try { + const output = execSync(`systemctl --user status ${UNIT_NAME}`, { encoding: 'utf-8' }) + return output.trim() + } catch (err) { + const stderr = (err as any).stderr?.toString() ?? '' + if (stderr.includes('could not be found')) { + return 'Not installed' + } + // systemctl returns non-zero for inactive services too + return (err as any).stdout?.toString().trim() || 'Not running' + } +} diff --git a/src/sync.ts b/src/sync.ts new file mode 100644 index 0000000..ffbb638 --- /dev/null +++ b/src/sync.ts @@ -0,0 +1,270 @@ +import { SyncPriority } from './types.js' +import type { GenericNode } from './types.js' +import type { BaseCache } from './adapters/cache/base.js' +import { logger } from './logger.js' + +/** + * Interface that the SyncWorker uses to fetch metadata from the remote. + * Deliberately narrow so we don't depend on the full BaseClient type (avoids circular deps). + */ +export interface SyncableClient { + list(path: string): Promise +} + +/** An entry in the priority queue. */ +interface QueueEntry { + path: string + priority: SyncPriority + /** Resolvers for anyone awaiting forceSync on this path. */ + waiters: Array<{ resolve: () => void; reject: (err: Error) => void }> +} + +/** Minimum polling interval in milliseconds. */ +const MIN_BACKOFF_MS = 10_000 +/** Maximum polling interval in milliseconds (30 minutes). */ +const MAX_BACKOFF_MS = 30 * 60 * 1000 + +/** + * Background sync worker that keeps the cache up to date with the remote. + * + * Maintains a priority queue of paths to fetch. HIGH-priority items (user-requested + * or post-write) are processed before LOW-priority (background crawl) items. + * Runs `concurrency` fetches in parallel. + * + * After the initial sync, begins polling with exponential backoff (10s → 30min). + * Any detected change resets the backoff to 10s. + */ +export class SyncWorker { + private queue: Map = new Map() + private activeWorkers = 0 + private running = false + private backoffMs = MIN_BACKOFF_MS + private pollTimer: ReturnType | null = null + private drainResolvers: Array<() => void> = [] + + constructor( + private client: SyncableClient, + private cache: BaseCache, + private concurrency: number, + ) {} + + /** + * Starts the sync worker. Enqueues the root path at HIGH priority + * and begins processing the queue. + */ + start(): void { + if (this.running) return + this.running = true + this.enqueue('/', SyncPriority.HIGH) + this.pump() + } + + /** Stops the sync worker and clears the poll timer. */ + stop(): void { + this.running = false + if (this.pollTimer) { + clearTimeout(this.pollTimer) + this.pollTimer = null + } + } + + /** + * Promotes a path to HIGH priority and waits until it has been synced. + * Called after every write operation to ensure the cache is consistent. + */ + async forceSync(path: string): Promise { + return new Promise((resolve, reject) => { + const existing = this.queue.get(path) + if (existing) { + existing.priority = SyncPriority.HIGH + existing.waiters.push({ resolve, reject }) + } else { + this.queue.set(path, { + path, + priority: SyncPriority.HIGH, + waiters: [{ resolve, reject }], + }) + } + this.pump() + }) + } + + /** + * Promotes a path to HIGH priority without waiting for completion. + * Called when a user browses an uncached directory. + */ + prioritise(path: string): void { + const existing = this.queue.get(path) + if (existing) { + existing.priority = SyncPriority.HIGH + } else { + this.enqueue(path, SyncPriority.HIGH) + } + this.pump() + } + + /** + * Returns a promise that resolves when the queue is fully drained. + * Useful for testing. + */ + async waitForDrain(): Promise { + if (this.queue.size === 0 && this.activeWorkers === 0) return + return new Promise((resolve) => { + this.drainResolvers.push(resolve) + }) + } + + /** Adds a path to the queue if not already present. */ + private enqueue(path: string, priority: SyncPriority): void { + if (this.queue.has(path)) return + this.queue.set(path, { path, priority, waiters: [] }) + } + + /** + * Main pump loop. Dequeues items and spawns workers up to the concurrency limit. + * HIGH-priority items are always dequeued first. + */ + private pump(): void { + if (!this.running) return + + while (this.activeWorkers < this.concurrency && this.queue.size > 0) { + const entry = this.dequeueHighest() + if (!entry) break + + this.activeWorkers++ + this.processEntry(entry) + .catch((err) => { + logger.error(`Sync error for ${entry.path}: ${(err as Error).message}`) + for (const waiter of entry.waiters) { + waiter.reject(err as Error) + } + }) + .finally(() => { + this.activeWorkers-- + this.pump() + this.checkDrain() + }) + } + } + + /** Dequeues the highest-priority entry from the queue. */ + private dequeueHighest(): QueueEntry | null { + let best: QueueEntry | null = null + for (const entry of this.queue.values()) { + if (!best || entry.priority < best.priority) { + best = entry + } + } + if (best) { + this.queue.delete(best.path) + } + return best + } + + /** + * Processes a single queue entry: + * 1. Fetches remote listing for the path + * 2. Diffs against the cache + * 3. Applies changes (upsert new/changed, delete removed) + * 4. Enqueues child directories at LOW priority + * 5. Resets backoff if changes were detected + */ + private async processEntry(entry: QueueEntry): Promise { + const { path } = entry + + let remoteNodes: GenericNode[] + try { + remoteNodes = await this.client.list(path) + } catch (err) { + logger.error(`Failed to list remote path ${path}: ${(err as Error).message}`) + for (const waiter of entry.waiters) { + waiter.reject(err as Error) + } + return + } + + // Safety net: drop any entry that refers to the directory itself. + // Some remote servers include the parent in directory listings. + const normalisedPath = path.replace(/\/+$/, '') || '/' + remoteNodes = remoteNodes.filter(n => { + const np = n.path.replace(/\/+$/, '') || '/' + return np !== normalisedPath + }) + + const cachedNodes = await this.cache.children(path) + let changesDetected = false + + // Build a set of remote paths for quick lookup + const remotePaths = new Set(remoteNodes.map(n => n.path)) + + // Upsert new/changed nodes + for (const remoteNode of remoteNodes) { + const cached = await this.cache.get(remoteNode.path) + + const isChanged = !cached + || cached.etag !== remoteNode.etag + || cached.modified.getTime() !== remoteNode.modified.getTime() + || cached.size !== remoteNode.size + + if (isChanged) { + await this.cache.set(remoteNode.path, remoteNode) + changesDetected = true + } + + // Enqueue child directories for background crawl + if (remoteNode.isDir) { + this.enqueue(remoteNode.path, SyncPriority.LOW) + } + } + + // Delete nodes that no longer exist on the remote + for (const cachedNode of cachedNodes) { + if (!remotePaths.has(cachedNode.path)) { + await this.cache.delete(cachedNode.path) + changesDetected = true + } + } + + // Reset backoff if any changes were detected + if (changesDetected) { + this.backoffMs = MIN_BACKOFF_MS + logger.info(`Sync detected changes at ${path}`) + } + + // Resolve all waiters for this entry + for (const waiter of entry.waiters) { + waiter.resolve() + } + } + + /** Checks if the queue is drained and resolves any drain waiters. */ + private checkDrain(): void { + if (this.queue.size === 0 && this.activeWorkers === 0) { + for (const resolve of this.drainResolvers) { + resolve() + } + this.drainResolvers = [] + this.schedulePoll() + } + } + + /** + * Schedules the next background poll with exponential backoff. + * Re-enqueues the root path at LOW priority. + */ + private schedulePoll(): void { + if (!this.running) return + if (this.pollTimer) return + + this.pollTimer = setTimeout(() => { + this.pollTimer = null + if (!this.running) return + + this.enqueue('/', SyncPriority.LOW) + this.pump() + + // Increase backoff for next cycle (capped at MAX_BACKOFF_MS) + this.backoffMs = Math.min(this.backoffMs * 2, MAX_BACKOFF_MS) + }, this.backoffMs) + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..1bcaa38 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,101 @@ +import { Readable } from 'node:stream' +import { z } from 'zod' + +/** + * Represents a single filesystem node (file or directory) in the virtual tree. + * All paths are POSIX-normalised: leading slash, no trailing slash. + */ +export interface GenericNode { + /** Full absolute path relative to client basePath, e.g. /docs/file.txt */ + path: string + /** Basename of the node */ + name: string + /** Whether this node is a directory */ + isDir: boolean + /** File size in bytes (0 for directories) */ + size: number + /** Last modification timestamp */ + modified: Date + /** Optional entity tag for change detection */ + etag?: string +} + +/** + * Union of all request types that servers dispatch to clients. + */ +export type GenericRequest = + | { type: 'stat'; path: string } + | { type: 'list'; path: string } + | { type: 'read'; path: string } + | { type: 'write'; path: string; stream: Readable } + | { type: 'mkdir'; path: string } + | { type: 'delete'; path: string } + | { type: 'rename'; from: string; to: string } + +/** + * Configuration for a single remote storage client. + */ +export interface ClientConfig { + /** Remote server URL */ + url: string + /** Root path on the remote server */ + basePath: string + /** Auth username */ + username: string + /** Auth password */ + password: string + /** Where this client appears in the local virtual filesystem, e.g. /home */ + mountPath: string + /** Number of parallel sync workers */ + concurrency: number +} + +/** + * Zod schema for validating the config file loaded from disk. + */ +export const ConfigSchema = z.object({ + port: z.number().int().positive().default(2022), + credentials: z.object({ + username: z.string().min(1), + password: z.string().min(1), + }), + servers: z.object({ + sftp: z.object({ port: z.number().int().positive() }).optional(), + ftp: z.object({ + port: z.number().int().positive(), + pasv_url: z.string().default('127.0.0.1'), + pasv_min: z.number().int().positive().default(1024), + pasv_max: z.number().int().positive().default(65535), + }).optional(), + webdav: z.object({ port: z.number().int().positive() }).optional(), + nfs: z.object({ port: z.number().int().positive() }).optional(), + smb: z.object({ port: z.number().int().positive() }).optional(), + }).optional(), + clients: z.array( + z.object({ + type: z.enum(['webdav', 'ftp', 'sftp']), + url: z.string().min(1), + basePath: z.string().default('/'), + username: z.string().min(1), + password: z.string().min(1), + mountPath: z.string().min(1), + concurrency: z.number().int().positive().default(5), + cache: z.object({ + type: z.enum(['sqlite', 'memory']).default('sqlite'), + }).default({ type: 'sqlite' }), + }) + ).min(1), +}) + +/** + * Fully validated config type inferred from the zod schema. + */ +export type Config = z.infer + +/** + * Priority levels for the sync queue. + */ +export enum SyncPriority { + HIGH = 0, + LOW = 1, +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ef321a5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*", "bin/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..1af69c9 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + testTimeout: 30000, + }, +})