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