workspace-sync: add config-driven rclone setup with dropbox.token
This commit is contained in:
parent
3e9339c77f
commit
ccbde17b9d
@ -15,6 +15,7 @@ import {
|
||||
isRcloneInstalled,
|
||||
ensureRcloneInstalled,
|
||||
isRcloneConfigured,
|
||||
ensureRcloneConfigFromConfig,
|
||||
resolveSyncConfig,
|
||||
runBisync,
|
||||
runSync,
|
||||
@ -115,6 +116,9 @@ export function registerWorkspaceCli(program: Command): void {
|
||||
|
||||
const resolved = resolveSyncConfig(syncConfig, workspaceDir, stateDir);
|
||||
|
||||
// Auto-generate rclone config from moltbot.json if credentials present
|
||||
ensureRcloneConfigFromConfig(syncConfig, resolved.configPath, resolved.remoteName);
|
||||
|
||||
// Check config
|
||||
if (!isRcloneConfigured(resolved.configPath, resolved.remoteName)) {
|
||||
console.error(
|
||||
@ -234,12 +238,16 @@ export function registerWorkspaceCli(program: Command): void {
|
||||
}
|
||||
console.log(colorize(rich, theme.success, "✓ rclone installed"));
|
||||
|
||||
// Auto-generate rclone config from moltbot.json if credentials present
|
||||
ensureRcloneConfigFromConfig(syncConfig, resolved.configPath, resolved.remoteName);
|
||||
|
||||
// Check config
|
||||
const configured = isRcloneConfigured(resolved.configPath, resolved.remoteName);
|
||||
if (!configured) {
|
||||
console.log(colorize(rich, theme.error, "✗ rclone not configured"));
|
||||
console.log("");
|
||||
console.log("Run: moltbot workspace authorize");
|
||||
console.log("Or add dropbox.token to workspace.sync config");
|
||||
return;
|
||||
}
|
||||
console.log(colorize(rich, theme.success, "✓ rclone configured"));
|
||||
@ -669,6 +677,9 @@ export function registerWorkspaceCli(program: Command): void {
|
||||
|
||||
const resolved = resolveSyncConfig(syncConfig, workspaceDir, stateDir);
|
||||
|
||||
// Auto-generate rclone config from moltbot.json if credentials present
|
||||
ensureRcloneConfigFromConfig(syncConfig, resolved.configPath, resolved.remoteName);
|
||||
|
||||
if (!isRcloneConfigured(resolved.configPath, resolved.remoteName)) {
|
||||
console.error(colorize(rich, theme.error, "rclone not configured."));
|
||||
console.error("Run: moltbot workspace authorize");
|
||||
|
||||
@ -101,10 +101,12 @@ export type WorkspaceSyncConfig = {
|
||||
dropbox?: {
|
||||
/** Use app folder access (more secure, limited to Apps/<app-name>/). */
|
||||
appFolder?: boolean;
|
||||
/** Dropbox app key (for app folder access). */
|
||||
/** Dropbox app key / client_id. */
|
||||
appKey?: string;
|
||||
/** Dropbox app secret (for app folder access). */
|
||||
/** Dropbox app secret / client_secret. */
|
||||
appSecret?: string;
|
||||
/** OAuth token JSON (prefer env var ${DROPBOX_TOKEN}). */
|
||||
token?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent
|
||||
import {
|
||||
isRcloneInstalled,
|
||||
isRcloneConfigured,
|
||||
ensureRcloneConfigFromConfig,
|
||||
resolveSyncConfig,
|
||||
runBisync,
|
||||
} from "../infra/rclone.js";
|
||||
@ -64,6 +65,9 @@ async function runSync(): Promise<void> {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(currentConfig, agentId);
|
||||
const resolved = resolveSyncConfig(syncConfig, workspaceDir);
|
||||
|
||||
// Auto-generate rclone config from moltbot.json if credentials present
|
||||
ensureRcloneConfigFromConfig(syncConfig, resolved.configPath, resolved.remoteName);
|
||||
|
||||
// Check if configured
|
||||
if (!isRcloneConfigured(resolved.configPath, resolved.remoteName)) {
|
||||
logger.warn(`[workspace-sync] rclone not configured for "${resolved.remoteName}", skipping`);
|
||||
|
||||
@ -21,6 +21,7 @@ vi.mock("../../../logging/subsystem.js", () => ({
|
||||
vi.mock("../../../infra/rclone.js", () => ({
|
||||
isRcloneInstalled: vi.fn(),
|
||||
isRcloneConfigured: vi.fn(),
|
||||
ensureRcloneConfigFromConfig: vi.fn(),
|
||||
resolveSyncConfig: vi.fn(),
|
||||
runBisync: vi.fn(),
|
||||
}));
|
||||
|
||||
@ -13,6 +13,7 @@ import type { HookHandler } from "../../hooks.js";
|
||||
import {
|
||||
isRcloneInstalled,
|
||||
isRcloneConfigured,
|
||||
ensureRcloneConfigFromConfig,
|
||||
resolveSyncConfig,
|
||||
runBisync,
|
||||
} from "../../../infra/rclone.js";
|
||||
@ -75,6 +76,9 @@ const workspaceSyncHandler: HookHandler = async (event) => {
|
||||
|
||||
const resolved = resolveSyncConfig(syncConfig, workspaceDir, stateDir);
|
||||
|
||||
// Auto-generate rclone config from moltbot.json if credentials present
|
||||
ensureRcloneConfigFromConfig(syncConfig, resolved.configPath, resolved.remoteName);
|
||||
|
||||
// Check if rclone is configured
|
||||
if (!isRcloneConfigured(resolved.configPath, resolved.remoteName)) {
|
||||
log.warn(`rclone not configured for remote "${resolved.remoteName}", skipping sync`);
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { resolveSyncConfig, generateRcloneConfig, isRcloneConfigured } from "./rclone.js";
|
||||
import {
|
||||
resolveSyncConfig,
|
||||
generateRcloneConfig,
|
||||
isRcloneConfigured,
|
||||
ensureRcloneConfigFromConfig,
|
||||
} from "./rclone.js";
|
||||
|
||||
describe("rclone helpers", () => {
|
||||
describe("resolveSyncConfig", () => {
|
||||
@ -155,4 +162,116 @@ describe("rclone helpers", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -282,6 +282,67 @@ export function isRcloneConfigured(configPath: string, remoteName: string): bool
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user