diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 5ccbcfea8..d39ecd9a7 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -23,6 +23,7 @@ import type { NodeHostConfig } from "./types.node-host.js"; import type { PluginsConfig } from "./types.plugins.js"; import type { SkillsConfig } from "./types.skills.js"; import type { ToolsConfig } from "./types.tools.js"; +import type { WorkspaceConfig } from "./types.workspace.js"; export type OpenClawConfig = { meta?: { @@ -95,6 +96,7 @@ export type OpenClawConfig = { canvasHost?: CanvasHostConfig; talk?: TalkConfig; gateway?: GatewayConfig; + workspace?: WorkspaceConfig; }; export type ConfigValidationIssue = { diff --git a/src/config/types.workspace.ts b/src/config/types.workspace.ts new file mode 100644 index 000000000..de1feb6ff --- /dev/null +++ b/src/config/types.workspace.ts @@ -0,0 +1,129 @@ +/** + * Workspace sync provider modes. + * - off: no sync + * - dropbox: Dropbox via rclone + * - gdrive: Google Drive via rclone + * - onedrive: OneDrive via rclone + * - s3: S3-compatible storage via rclone + * - custom: custom rclone remote (user-configured) + */ +export type WorkspaceSyncProvider = "off" | "dropbox" | "gdrive" | "onedrive" | "s3" | "custom"; + +/** + * Workspace sync configuration. + * Enables bidirectional sync between the agent workspace and cloud storage. + */ +export type WorkspaceSyncConfig = { + /** + * Sync provider mode. + * - off: disabled (default) + * - dropbox: Dropbox via rclone + * - gdrive: Google Drive via rclone + * - onedrive: OneDrive via rclone + * - s3: S3-compatible storage via rclone + * - custom: custom rclone remote + */ + provider?: WorkspaceSyncProvider; + + /** + * Remote path/folder in cloud storage (e.g., "moltbot-share"). + * For Dropbox App folders, this is relative to the app folder root. + */ + remotePath?: string; + + /** + * Local subfolder within workspace to sync (default: "shared"). + * Files outside this folder are not synced. + */ + localPath?: string; + + /** + * Sync interval in seconds (0 = manual only, default: 0). + * When > 0, the gateway runs rclone bisync in the background at this interval. + * This is a pure file operation - it does NOT wake the bot or incur LLM costs. + */ + interval?: number; + + /** + * Sync on session start (default: false). + */ + onSessionStart?: boolean; + + /** + * Sync on session end (default: false). + */ + onSessionEnd?: boolean; + + /** + * rclone remote name (default: "cloud"). + * Used when provider is "custom" or to override the auto-generated name. + */ + remoteName?: string; + + /** + * Path to rclone config file. + * Default: $CLAWDBOT_STATE_DIR/.config/rclone/rclone.conf + */ + configPath?: string; + + /** + * Conflict resolution strategy. + * - newer: keep the newer file, rename older with .conflict suffix + * - local: local wins, remote gets .conflict suffix + * - remote: remote wins, local gets .conflict suffix + */ + conflictResolve?: "newer" | "local" | "remote"; + + /** + * File patterns to exclude from sync (glob patterns). + * Defaults include: .git/**, node_modules/**, .venv/**, *.log, .DS_Store + */ + exclude?: string[]; + + /** + * Follow symlinks during sync (default: false). + * When false, symlinks are skipped with a notice. + * When true, symlinks are followed and their targets are copied. + */ + copySymlinks?: boolean; + + /** + * S3-specific configuration (when provider is "s3"). + */ + s3?: { + /** S3 endpoint URL (for non-AWS S3-compatible services). */ + endpoint?: string; + /** S3 bucket name. */ + bucket?: string; + /** S3 region. */ + region?: string; + /** Access key ID (prefer env var S3_ACCESS_KEY_ID). */ + accessKeyId?: string; + /** Secret access key (prefer env var S3_SECRET_ACCESS_KEY). */ + secretAccessKey?: string; + }; + + /** + * Dropbox-specific configuration. + */ + dropbox?: { + /** Use app folder access (more secure, limited to Apps//). */ + appFolder?: boolean; + /** Dropbox app key / client_id. */ + appKey?: string; + /** Dropbox app secret / client_secret. */ + appSecret?: string; + /** OAuth token JSON (prefer env var ${DROPBOX_TOKEN}). */ + token?: string; + }; +}; + +/** + * Top-level workspace configuration. + */ +export type WorkspaceConfig = { + /** + * Cloud sync configuration for bidirectional workspace sync. + */ + sync?: WorkspaceSyncConfig; +}; diff --git a/src/infra/rclone.test.ts b/src/infra/rclone.test.ts new file mode 100644 index 000000000..f1ecc6266 --- /dev/null +++ b/src/infra/rclone.test.ts @@ -0,0 +1,277 @@ +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; +import { describe, expect, it, beforeEach, afterEach } from "vitest"; + +import { + resolveSyncConfig, + generateRcloneConfig, + isRcloneConfigured, + ensureRcloneConfigFromConfig, +} from "./rclone.js"; + +describe("rclone helpers", () => { + describe("resolveSyncConfig", () => { + it("uses defaults when config is minimal", () => { + const config = { provider: "dropbox" as const, remotePath: "test-folder" }; + const workspaceDir = path.join("/home", "user", "workspace"); + const stateDir = path.join("/home", "user", ".moltbot"); + + const resolved = resolveSyncConfig(config, workspaceDir, stateDir); + + expect(resolved.provider).toBe("dropbox"); + expect(resolved.remotePath).toBe("test-folder"); + expect(resolved.localPath).toBe(path.join(workspaceDir, "shared")); + expect(resolved.remoteName).toBe("cloud"); + expect(resolved.conflictResolve).toBe("newer"); + expect(resolved.interval).toBe(0); + expect(resolved.onSessionStart).toBe(false); + expect(resolved.onSessionEnd).toBe(false); + }); + + it("respects custom localPath", () => { + const config = { + provider: "dropbox" as const, + remotePath: "test-folder", + localPath: "sync", + }; + const workspaceDir = path.join("/home", "user", "workspace"); + const stateDir = path.join("/home", "user", ".moltbot"); + + const resolved = resolveSyncConfig(config, workspaceDir, stateDir); + + expect(resolved.localPath).toBe(path.join(workspaceDir, "sync")); + }); + + it("respects custom remoteName", () => { + const config = { + provider: "dropbox" as const, + remotePath: "test-folder", + remoteName: "my-dropbox", + }; + const workspaceDir = path.join("/home", "user", "workspace"); + const stateDir = path.join("/home", "user", ".moltbot"); + + const resolved = resolveSyncConfig(config, workspaceDir, stateDir); + + expect(resolved.remoteName).toBe("my-dropbox"); + }); + + it("respects interval and session hooks", () => { + const config = { + provider: "dropbox" as const, + remotePath: "test-folder", + interval: 300, + onSessionStart: true, + onSessionEnd: true, + }; + const workspaceDir = path.join("/home", "user", "workspace"); + const stateDir = path.join("/home", "user", ".moltbot"); + + const resolved = resolveSyncConfig(config, workspaceDir, stateDir); + + expect(resolved.interval).toBe(300); + expect(resolved.onSessionStart).toBe(true); + expect(resolved.onSessionEnd).toBe(true); + }); + + it("applies default excludes", () => { + const config = { provider: "dropbox" as const, remotePath: "test" }; + const resolved = resolveSyncConfig(config, "/workspace", "/state"); + + expect(resolved.exclude).toContain(".git/**"); + expect(resolved.exclude).toContain("node_modules/**"); + }); + + it("respects custom excludes", () => { + const config = { + provider: "dropbox" as const, + remotePath: "test", + exclude: ["*.tmp", "cache/**"], + }; + const resolved = resolveSyncConfig(config, "/workspace", "/state"); + + expect(resolved.exclude).toEqual(["*.tmp", "cache/**"]); + }); + }); + + describe("generateRcloneConfig", () => { + it("generates dropbox config with token", () => { + const config = generateRcloneConfig("dropbox", "cloud", '{"access_token":"abc123"}'); + + expect(config).toContain("[cloud]"); + expect(config).toContain("type = dropbox"); + expect(config).toContain('token = {"access_token":"abc123"}'); + }); + + it("generates gdrive config", () => { + const config = generateRcloneConfig("gdrive", "drive", '{"access_token":"xyz"}'); + + expect(config).toContain("[drive]"); + expect(config).toContain("type = drive"); + expect(config).toContain('token = {"access_token":"xyz"}'); + }); + + it("generates onedrive config", () => { + const config = generateRcloneConfig("onedrive", "od", '{"access_token":"123"}'); + + expect(config).toContain("[od]"); + expect(config).toContain("type = onedrive"); + }); + + it("includes app key/secret for dropbox app folder", () => { + const config = generateRcloneConfig("dropbox", "cloud", '{"access_token":"abc"}', { + dropbox: { appKey: "key123", appSecret: "secret456" }, + }); + + expect(config).toContain("client_id = key123"); + expect(config).toContain("client_secret = secret456"); + }); + + it("generates s3 config with endpoint", () => { + const config = generateRcloneConfig("s3", "r2", "", { + s3: { + endpoint: "https://xxx.r2.cloudflarestorage.com", + accessKeyId: "AKID", + secretAccessKey: "SECRET", + }, + }); + + expect(config).toContain("[r2]"); + expect(config).toContain("type = s3"); + expect(config).toContain("endpoint = https://xxx.r2.cloudflarestorage.com"); + expect(config).toContain("access_key_id = AKID"); + expect(config).toContain("secret_access_key = SECRET"); + }); + + it("includes region for s3 when provided", () => { + const config = generateRcloneConfig("s3", "aws", "", { + s3: { + region: "us-east-1", + bucket: "my-bucket", + }, + }); + + expect(config).toContain("region = us-east-1"); + }); + }); + + describe("isRcloneConfigured", () => { + it("returns false when config file does not exist", () => { + const result = isRcloneConfigured("/nonexistent/path/rclone.conf", "cloud"); + expect(result).toBe(false); + }); + }); + + describe("ensureRcloneConfigFromConfig", () => { + let tempDir: string; + let configPath: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "rclone-test-")); + configPath = path.join(tempDir, "rclone.conf"); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("returns false when provider is off", () => { + const result = ensureRcloneConfigFromConfig({ provider: "off" }, configPath, "cloud"); + expect(result).toBe(false); + expect(fs.existsSync(configPath)).toBe(false); + }); + + it("returns false when provider is undefined", () => { + const result = ensureRcloneConfigFromConfig(undefined, configPath, "cloud"); + expect(result).toBe(false); + }); + + it("returns false when dropbox has no token", () => { + const result = ensureRcloneConfigFromConfig( + { provider: "dropbox", dropbox: { appKey: "key", appSecret: "secret" } }, + configPath, + "cloud", + ); + expect(result).toBe(false); + expect(fs.existsSync(configPath)).toBe(false); + }); + + it("generates config when dropbox has token", () => { + const result = ensureRcloneConfigFromConfig( + { + provider: "dropbox", + dropbox: { + token: '{"access_token":"test123"}', + appKey: "mykey", + appSecret: "mysecret", + }, + }, + configPath, + "cloud", + ); + + expect(result).toBe(true); + expect(fs.existsSync(configPath)).toBe(true); + + const content = fs.readFileSync(configPath, "utf-8"); + expect(content).toContain("[cloud]"); + expect(content).toContain("type = dropbox"); + expect(content).toContain('token = {"access_token":"test123"}'); + expect(content).toContain("client_id = mykey"); + expect(content).toContain("client_secret = mysecret"); + }); + + it("returns true without regenerating when config already exists", () => { + // Create existing config + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, "[cloud]\ntype = dropbox\ntoken = old"); + + const result = ensureRcloneConfigFromConfig( + { provider: "dropbox", dropbox: { token: '{"new":"token"}' } }, + configPath, + "cloud", + ); + + expect(result).toBe(true); + // Should NOT overwrite existing config + const content = fs.readFileSync(configPath, "utf-8"); + expect(content).toContain("token = old"); + }); + + it("returns false when s3 has no credentials", () => { + const result = ensureRcloneConfigFromConfig( + { provider: "s3", s3: { endpoint: "https://example.com" } }, + configPath, + "cloud", + ); + expect(result).toBe(false); + }); + + it("generates config when s3 has credentials", () => { + const result = ensureRcloneConfigFromConfig( + { + provider: "s3", + s3: { + endpoint: "https://r2.example.com", + accessKeyId: "AKID123", + secretAccessKey: "SECRET456", + region: "auto", + }, + }, + configPath, + "r2", + ); + + expect(result).toBe(true); + expect(fs.existsSync(configPath)).toBe(true); + + const content = fs.readFileSync(configPath, "utf-8"); + expect(content).toContain("[r2]"); + expect(content).toContain("type = s3"); + expect(content).toContain("endpoint = https://r2.example.com"); + expect(content).toContain("access_key_id = AKID123"); + expect(content).toContain("secret_access_key = SECRET456"); + }); + }); +}); diff --git a/src/infra/rclone.ts b/src/infra/rclone.ts new file mode 100644 index 000000000..ec18af2ff --- /dev/null +++ b/src/infra/rclone.ts @@ -0,0 +1,598 @@ +import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir, platform } from "node:os"; +import { logVerbose } from "../globals.js"; +import { runExec } from "../process/exec.js"; +import type { WorkspaceSyncConfig, WorkspaceSyncProvider } from "../config/types.workspace.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { defaultRuntime } from "../runtime.js"; + +const DEFAULT_REMOTE_NAME = "cloud"; +const DEFAULT_LOCAL_PATH = "shared"; +const DEFAULT_REMOTE_PATH = "moltbot-share"; +const DEFAULT_CONFLICT_RESOLVE = "newer"; +const DEFAULT_EXCLUDES = [ + ".git/**", + "node_modules/**", + ".venv/**", + "__pycache__/**", + "*.log", + ".DS_Store", +]; + +export type RcloneSyncResult = { + ok: boolean; + error?: string; + filesTransferred?: number; + bytesTransferred?: number; +}; + +/** + * Find rclone binary in PATH or common locations. + */ +export async function findRcloneBinary(): Promise { + const checkBinary = async (path: string): Promise => { + if (!path || (path.startsWith("/") && !existsSync(path))) return false; + try { + await runExec(path, ["--version"], { timeoutMs: 3000 }); + return true; + } catch { + return false; + } + }; + + // Strategy 1: which command + try { + const { stdout } = await runExec("which", ["rclone"]); + const fromPath = stdout.trim(); + if (fromPath && (await checkBinary(fromPath))) { + return fromPath; + } + } catch { + // which failed, continue + } + + // Strategy 2: Common install locations + const commonPaths = ["/usr/local/bin/rclone", "/usr/bin/rclone", "/opt/homebrew/bin/rclone"]; + for (const path of commonPaths) { + if (await checkBinary(path)) { + return path; + } + } + + return null; +} + +let cachedRcloneBinary: string | null = null; + +export async function getRcloneBinary(): Promise { + if (cachedRcloneBinary) return cachedRcloneBinary; + cachedRcloneBinary = await findRcloneBinary(); + return cachedRcloneBinary ?? "rclone"; +} + +/** + * Check if rclone is installed. + */ +export async function isRcloneInstalled(): Promise { + const binary = await findRcloneBinary(); + return binary !== null; +} + +/** + * Ensure rclone is installed, offering to install if missing. + * Returns true if rclone is available, false if user declined install. + */ +export async function ensureRcloneInstalled( + prompt: (message: string, defaultValue: boolean) => Promise, + exec: typeof runExec = runExec, + runtime: RuntimeEnv = defaultRuntime, +): Promise { + const installed = await isRcloneInstalled(); + if (installed) return true; + + const isMac = platform() === "darwin"; + const isLinux = platform() === "linux"; + + if (isMac) { + // Check if Homebrew is available + const hasBrew = await exec("which", ["brew"]).then( + () => true, + () => false, + ); + + if (hasBrew) { + const install = await prompt( + "rclone not found. Install via Homebrew (brew install rclone)?", + true, + ); + if (!install) { + return false; + } + logVerbose("Installing rclone via Homebrew..."); + try { + await exec("brew", ["install", "rclone"], { timeoutMs: 120_000 }); + // Clear cached binary so we find the new one + cachedRcloneBinary = null; + return true; + } catch (err) { + runtime.error( + `Failed to install rclone: ${err instanceof Error ? err.message : String(err)}`, + ); + return false; + } + } + } + + if (isLinux || isMac) { + const install = await prompt( + "rclone not found. Install via official script (curl https://rclone.org/install.sh)?", + true, + ); + if (!install) { + return false; + } + logVerbose("Installing rclone via official script..."); + try { + // Download and run the install script + const { stdout } = await exec("curl", ["-s", "https://rclone.org/install.sh"], { + timeoutMs: 30_000, + }); + await exec("sudo", ["bash", "-c", stdout], { timeoutMs: 120_000 }); + // Clear cached binary so we find the new one + cachedRcloneBinary = null; + return true; + } catch (err) { + runtime.error( + `Failed to install rclone: ${err instanceof Error ? err.message : String(err)}`, + ); + runtime.error("Try installing manually: https://rclone.org/install/"); + return false; + } + } + + runtime.error("rclone not found. Please install manually: https://rclone.org/install/"); + return false; +} + +/** + * Get the default rclone config path. + */ +export function getDefaultRcloneConfigPath(stateDir?: string): string { + const base = stateDir ?? process.env.CLAWDBOT_STATE_DIR ?? join(homedir(), ".clawdbot"); + return join(base, ".config", "rclone", "rclone.conf"); +} + +/** + * Resolve sync config with defaults. + */ +export function resolveSyncConfig( + config: WorkspaceSyncConfig | undefined, + workspace: string, + stateDir?: string, +): { + provider: WorkspaceSyncProvider; + remoteName: string; + remotePath: string; + localPath: string; + configPath: string; + conflictResolve: "newer" | "local" | "remote"; + exclude: string[]; + copySymlinks: boolean; + interval: number; + onSessionStart: boolean; + onSessionEnd: boolean; +} { + return { + provider: config?.provider ?? "off", + remoteName: config?.remoteName ?? DEFAULT_REMOTE_NAME, + remotePath: config?.remotePath ?? DEFAULT_REMOTE_PATH, + localPath: join(workspace, config?.localPath ?? DEFAULT_LOCAL_PATH), + configPath: config?.configPath ?? getDefaultRcloneConfigPath(stateDir), + conflictResolve: config?.conflictResolve ?? DEFAULT_CONFLICT_RESOLVE, + exclude: config?.exclude ?? DEFAULT_EXCLUDES, + copySymlinks: config?.copySymlinks ?? false, + interval: config?.interval ?? 0, + onSessionStart: config?.onSessionStart ?? false, + onSessionEnd: config?.onSessionEnd ?? false, + }; +} + +/** + * Get rclone type string for a provider. + */ +function getRcloneType(provider: WorkspaceSyncProvider): string { + switch (provider) { + case "dropbox": + return "dropbox"; + case "gdrive": + return "drive"; + case "onedrive": + return "onedrive"; + case "s3": + return "s3"; + default: + return "unknown"; + } +} + +/** + * Generate rclone config content for a provider. + */ +export function generateRcloneConfig( + provider: WorkspaceSyncProvider, + remoteName: string, + token: string, + options?: { + dropbox?: { appKey?: string; appSecret?: string }; + s3?: { + endpoint?: string; + bucket?: string; + region?: string; + accessKeyId?: string; + secretAccessKey?: string; + }; + }, +): string { + const type = getRcloneType(provider); + let config = `[${remoteName}]\ntype = ${type}\n`; + + if (provider === "dropbox") { + config += `token = ${token}\n`; + if (options?.dropbox?.appKey) { + config += `client_id = ${options.dropbox.appKey}\n`; + } + if (options?.dropbox?.appSecret) { + config += `client_secret = ${options.dropbox.appSecret}\n`; + } + } else if (provider === "gdrive") { + config += `token = ${token}\n`; + } else if (provider === "onedrive") { + config += `token = ${token}\n`; + } else if (provider === "s3") { + if (options?.s3?.endpoint) { + config += `endpoint = ${options.s3.endpoint}\n`; + } + if (options?.s3?.region) { + config += `region = ${options.s3.region}\n`; + } + if (options?.s3?.accessKeyId) { + config += `access_key_id = ${options.s3.accessKeyId}\n`; + } + if (options?.s3?.secretAccessKey) { + config += `secret_access_key = ${options.s3.secretAccessKey}\n`; + } + } + + return config; +} + +/** + * Write rclone config to disk. + */ +export function writeRcloneConfig(configPath: string, content: string): void { + const dir = dirname(configPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(configPath, content, { mode: 0o600 }); +} + +/** + * Check if rclone config exists and has the remote configured. + */ +export function isRcloneConfigured(configPath: string, remoteName: string): boolean { + if (!existsSync(configPath)) return false; + try { + const content = readFileSync(configPath, "utf-8"); + return content.includes(`[${remoteName}]`); + } catch { + return false; + } +} + +/** + * Ensure rclone config exists, auto-generating from moltbot.json config if credentials are present. + * This allows users to configure sync entirely via moltbot.json + env vars without manual rclone setup. + * + * @returns true if config exists or was generated, false if credentials are missing + */ +export function ensureRcloneConfigFromConfig( + syncConfig: WorkspaceSyncConfig | undefined, + configPath: string, + remoteName: string, +): boolean { + // If config already exists with this remote, we're good + if (isRcloneConfigured(configPath, remoteName)) { + return true; + } + + if (!syncConfig?.provider || syncConfig.provider === "off") { + return false; + } + + // For Dropbox: need token (appKey/appSecret optional but recommended) + if (syncConfig.provider === "dropbox") { + const token = syncConfig.dropbox?.token; + if (!token) { + return false; + } + + logVerbose(`[rclone] Auto-generating config for ${remoteName} from moltbot.json credentials`); + + const configContent = generateRcloneConfig(syncConfig.provider, remoteName, token, { + dropbox: { + appKey: syncConfig.dropbox?.appKey, + appSecret: syncConfig.dropbox?.appSecret, + }, + }); + + writeRcloneConfig(configPath, configContent); + return true; + } + + // For S3: need accessKeyId and secretAccessKey + if (syncConfig.provider === "s3") { + const { accessKeyId, secretAccessKey, endpoint, bucket, region } = syncConfig.s3 ?? {}; + if (!accessKeyId || !secretAccessKey) { + return false; + } + + logVerbose(`[rclone] Auto-generating config for ${remoteName} from moltbot.json credentials`); + + const configContent = generateRcloneConfig(syncConfig.provider, remoteName, "", { + s3: { endpoint, bucket, region, accessKeyId, secretAccessKey }, + }); + + writeRcloneConfig(configPath, configContent); + return true; + } + + // Other providers require manual rclone config + return false; +} + +/** + * Run rclone authorize command (returns the token). + * This must be run on a machine with a browser. + */ +export async function authorizeRclone( + provider: WorkspaceSyncProvider, + appKey?: string, + appSecret?: string, +): Promise<{ ok: true; token: string } | { ok: false; error: string }> { + const rcloneBin = await getRcloneBinary(); + const type = getRcloneType(provider); + + const args = ["authorize", type]; + if (appKey && appSecret) { + args.push(appKey, appSecret); + } + + try { + const { stdout, stderr } = await runExec(rcloneBin, args, { + timeoutMs: 300_000, // 5 minutes for OAuth flow + maxBuffer: 1_000_000, + }); + + // Extract token from output + const combined = stdout + stderr; + const tokenMatch = combined.match(/\{[^}]*"access_token"[^}]*\}/); + if (tokenMatch) { + return { ok: true, token: tokenMatch[0] }; + } + + // Try to find JSON object in output + const jsonMatch = combined.match( + /Paste the following into your remote machine[\s\S]*?(\{[\s\S]*?\})\s*$/m, + ); + if (jsonMatch) { + return { ok: true, token: jsonMatch[1] }; + } + + return { ok: false, error: "Could not extract token from rclone output" }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, error: `Authorization failed: ${message}` }; + } +} + +/** + * Run bidirectional sync using rclone bisync. + */ +export async function runBisync(params: { + configPath: string; + remoteName: string; + remotePath: string; + localPath: string; + conflictResolve: "newer" | "local" | "remote"; + exclude: string[]; + copySymlinks?: boolean; + resync?: boolean; + dryRun?: boolean; + verbose?: boolean; +}): Promise { + const rcloneBin = await getRcloneBinary(); + + // Ensure local directory exists + if (!existsSync(params.localPath)) { + mkdirSync(params.localPath, { recursive: true }); + } + + const args = [ + "bisync", + `${params.remoteName}:${params.remotePath}`, + params.localPath, + "--config", + params.configPath, + "--conflict-resolve", + params.conflictResolve, + "--conflict-suffix", + ".conflict", + ]; + + // Add excludes + for (const pattern of params.exclude) { + args.push("--exclude", pattern); + } + + // Follow symlinks if configured + if (params.copySymlinks) { + args.push("--copy-links"); + } + + if (params.resync) { + args.push("--resync"); + } + + if (params.dryRun) { + args.push("--dry-run"); + } + + if (params.verbose) { + args.push("--verbose"); + } else { + // Suppress NOTICE messages (e.g., symlink warnings) unless verbose + args.push("--log-level", "WARNING"); + } + + logVerbose(`Running: ${rcloneBin} ${args.join(" ")}`); + + try { + const { stdout, stderr } = await runExec(rcloneBin, args, { + timeoutMs: 600_000, // 10 minutes + maxBuffer: 10_000_000, + }); + + // Parse output for stats + const combined = stdout + stderr; + const transferredMatch = combined.match(/Transferred:\s*(\d+)\s*\/\s*(\d+)/); + + return { + ok: true, + filesTransferred: transferredMatch ? parseInt(transferredMatch[1], 10) : undefined, + }; + } catch (err) { + const errObj = err as { stdout?: string; stderr?: string; message?: string }; + const message = + errObj.stderr?.trim() || errObj.stdout?.trim() || errObj.message || "Unknown sync error"; + + // Check for common errors + if (message.includes("bisync requires --resync")) { + return { + ok: false, + error: "First sync requires --resync flag to establish baseline", + }; + } + + return { ok: false, error: message }; + } +} + +/** + * Run one-way sync (copy) from remote to local or vice versa. + */ +export async function runSync(params: { + configPath: string; + remoteName: string; + remotePath: string; + localPath: string; + direction: "pull" | "push"; + exclude: string[]; + dryRun?: boolean; + verbose?: boolean; +}): Promise { + const rcloneBin = await getRcloneBinary(); + + // Ensure local directory exists + if (!existsSync(params.localPath)) { + mkdirSync(params.localPath, { recursive: true }); + } + + const remote = `${params.remoteName}:${params.remotePath}`; + const [source, dest] = + params.direction === "pull" ? [remote, params.localPath] : [params.localPath, remote]; + + const args = ["sync", source, dest, "--config", params.configPath]; + + for (const pattern of params.exclude) { + args.push("--exclude", pattern); + } + + if (params.dryRun) { + args.push("--dry-run"); + } + + if (params.verbose) { + args.push("--verbose"); + } else { + // Suppress NOTICE messages (e.g., symlink warnings) unless verbose + args.push("--log-level", "WARNING"); + } + + logVerbose(`Running: ${rcloneBin} ${args.join(" ")}`); + + try { + await runExec(rcloneBin, args, { + timeoutMs: 600_000, + maxBuffer: 10_000_000, + }); + return { ok: true }; + } catch (err) { + const errObj = err as { stderr?: string; message?: string }; + const message = errObj.stderr?.trim() || errObj.message || "Unknown sync error"; + return { ok: false, error: message }; + } +} + +/** + * List files in remote. + */ +export async function listRemote(params: { + configPath: string; + remoteName: string; + remotePath: string; +}): Promise<{ ok: true; files: string[] } | { ok: false; error: string }> { + const rcloneBin = await getRcloneBinary(); + + try { + const { stdout } = await runExec( + rcloneBin, + ["lsf", `${params.remoteName}:${params.remotePath}`, "--config", params.configPath], + { timeoutMs: 30_000, maxBuffer: 1_000_000 }, + ); + + const files = stdout + .trim() + .split("\n") + .filter((f) => f.length > 0); + return { ok: true, files }; + } catch (err) { + const errObj = err as { stderr?: string; message?: string }; + const message = errObj.stderr?.trim() || errObj.message || "Unknown error"; + return { ok: false, error: message }; + } +} + +/** + * Check remote connection. + */ +export async function checkRemote(params: { + configPath: string; + remoteName: string; +}): Promise<{ ok: true } | { ok: false; error: string }> { + const rcloneBin = await getRcloneBinary(); + + try { + await runExec( + rcloneBin, + ["about", `${params.remoteName}:`, "--config", params.configPath, "--json"], + { timeoutMs: 30_000, maxBuffer: 100_000 }, + ); + return { ok: true }; + } catch (err) { + const errObj = err as { stderr?: string; message?: string }; + const message = errObj.stderr?.trim() || errObj.message || "Connection failed"; + return { ok: false, error: message }; + } +}