This commit is contained in:
Ash Brener 2026-01-30 17:05:38 +05:30 committed by GitHub
commit 3212746888
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 1006 additions and 0 deletions

View File

@ -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 = {

View File

@ -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/<app-name>/). */
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;
};

277
src/infra/rclone.test.ts Normal file
View File

@ -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");
});
});
});

598
src/infra/rclone.ts Normal file
View File

@ -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<string | null> {
const checkBinary = async (path: string): Promise<boolean> => {
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<string> {
if (cachedRcloneBinary) return cachedRcloneBinary;
cachedRcloneBinary = await findRcloneBinary();
return cachedRcloneBinary ?? "rclone";
}
/**
* Check if rclone is installed.
*/
export async function isRcloneInstalled(): Promise<boolean> {
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<boolean>,
exec: typeof runExec = runExec,
runtime: RuntimeEnv = defaultRuntime,
): Promise<boolean> {
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<RcloneSyncResult> {
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<RcloneSyncResult> {
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 };
}
}