From 5e881c9ff729462e292e84f9e7a9d39cf6ff7c99 Mon Sep 17 00:00:00 2001 From: ff Date: Wed, 24 Jun 2026 23:29:36 -0400 Subject: [PATCH] feat: integrate rclone WebDAV backup for PC-to-PC file sync Source PC serves backups/ folder via rclone WebDAV server (auto-starts with app). Receiver PC pulls backups on schedule using rclone sync. Network Backup UI now has two tabs: Rclone and API Key. Rclone installed automatically via postinstall script. Co-Authored-By: Claude Sonnet 4.6 --- apps/Backend/src/app.ts | 2 + apps/Backend/src/cron/backupCheck.ts | 38 ++ .../Backend/src/routes/database-management.ts | 61 ++ .../src/services/rcloneConfigService.ts | 50 ++ apps/Backend/src/services/rcloneService.ts | 140 +++++ .../network-backup-manager.tsx | 555 +++++++++++++----- package.json | 2 +- scripts/install-rclone.sh | 23 + 8 files changed, 723 insertions(+), 148 deletions(-) create mode 100644 apps/Backend/src/services/rcloneConfigService.ts create mode 100644 apps/Backend/src/services/rcloneService.ts create mode 100755 scripts/install-rclone.sh diff --git a/apps/Backend/src/app.ts b/apps/Backend/src/app.ts index ebd9d774..96494071 100755 --- a/apps/Backend/src/app.ts +++ b/apps/Backend/src/app.ts @@ -10,6 +10,7 @@ import { authenticateJWT } from "./middlewares/auth.middleware"; import networkBackupPublicRoutes from "./routes/network-backup-public"; import dotenv from "dotenv"; import { startBackupCron } from "./cron/backupCheck"; +import { autoStartServer } from "./services/rcloneService"; import path from "path"; dotenv.config(); @@ -87,5 +88,6 @@ app.use(errorHandler); //startig cron job startBackupCron(); +autoStartServer(); export default app; diff --git a/apps/Backend/src/cron/backupCheck.ts b/apps/Backend/src/cron/backupCheck.ts index 40d99b44..0bc4f030 100755 --- a/apps/Backend/src/cron/backupCheck.ts +++ b/apps/Backend/src/cron/backupCheck.ts @@ -6,6 +6,8 @@ import { backupDatabaseToPath } from "../services/databaseBackupService"; import { cronJobLogStorage } from "../storage/cron-job-log-storage"; import { readSyncConfig, writeSyncConfig } from "../services/networkSyncConfigService"; import { runNetworkSync, runNetworkFilesSync } from "../services/networkSyncService"; +import { readRcloneConfig, writeRcloneConfig } from "../services/rcloneConfigService"; +import { runRclonePull } from "../services/rcloneService"; // Local backup folder in the app root (apps/Backend/backups) const LOCAL_BACKUP_DIR = path.resolve(process.cwd(), "backups"); @@ -174,6 +176,42 @@ export const startBackupCron = () => { console.log("✅ [9 PM] USB backup complete."); }); + // ============================================================ + // Every hour — Rclone backup (runs only when hour matches config) + // ============================================================ + cron.schedule("0 * * * *", async () => { + const rcloneConfig = readRcloneConfig(); + if (!rcloneConfig.receiverEnabled || !rcloneConfig.sourceIp) return; + + const currentHour = new Date().getHours(); + if (currentHour !== rcloneConfig.receiverSyncHour) return; + + console.log(`[${rcloneConfig.receiverSyncHour}:00] Running rclone pull from ${rcloneConfig.sourceIp}...`); + + const admin = await getAdminUser(); + const startedAt = new Date(); + const log = await cronJobLogStorage.createJobLog("rclone-backup", startedAt); + + try { + await runRclonePull(); + writeRcloneConfig({ lastSyncAt: new Date().toISOString(), lastSyncStatus: "success", lastSyncError: null }); + await cronJobLogStorage.completeJobLog(log.id, "success", new Date()); + console.log(`Rclone backup complete.`); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + console.error("Rclone backup failed:", err); + writeRcloneConfig({ lastSyncAt: new Date().toISOString(), lastSyncStatus: "failed", lastSyncError: errorMessage }); + await cronJobLogStorage.completeJobLog(log.id, "failed", new Date(), errorMessage); + if (admin) { + await storage.createNotification( + admin.id, + "BACKUP", + `Rclone backup failed: ${errorMessage}` + ); + } + } + }); + // ============================================================ // Every hour — Network sync (runs only when hour matches config) // ============================================================ diff --git a/apps/Backend/src/routes/database-management.ts b/apps/Backend/src/routes/database-management.ts index 7738d83f..a29458bb 100755 --- a/apps/Backend/src/routes/database-management.ts +++ b/apps/Backend/src/routes/database-management.ts @@ -15,6 +15,8 @@ import { writeSyncConfig, } from "../services/networkSyncConfigService"; import { runNetworkSync, runNetworkFilesSync } from "../services/networkSyncService"; +import { readRcloneConfig, writeRcloneConfig } from "../services/rcloneConfigService"; +import { checkRcloneInstalled, isServerRunning, startWebDavServer, stopWebDavServer, runRclonePull } from "../services/rcloneService"; const UPLOADS_DIR = path.join(process.cwd(), "uploads"); @@ -605,4 +607,63 @@ router.post("/network-sync-now", async (req: Request, res: Response): Promise { + if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); + res.json(readRcloneConfig()); +}); + +router.put("/rclone-config", (req: Request, res: Response): any => { + if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); + const { serverEnabled, serverPort, receiverEnabled, receiverSyncHour, sourceIp, sourcePort } = req.body; + const patch: any = {}; + if (typeof serverEnabled === "boolean") patch.serverEnabled = serverEnabled; + if (typeof serverPort === "number") patch.serverPort = serverPort; + if (typeof receiverEnabled === "boolean") patch.receiverEnabled = receiverEnabled; + if (typeof receiverSyncHour === "number") patch.receiverSyncHour = receiverSyncHour; + if (typeof sourceIp === "string") patch.sourceIp = sourceIp.trim(); + if (typeof sourcePort === "number") patch.sourcePort = sourcePort; + const updated = writeRcloneConfig(patch); + res.json(updated); +}); + +router.get("/rclone-status", async (req, res) => { + if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); + const installed = await checkRcloneInstalled(); + const serverRunning = isServerRunning(); + res.json({ installed, serverRunning }); +}); + +router.post("/rclone-server/start", async (req: Request, res: Response): Promise => { + if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); + try { + await startWebDavServer(); + res.json({ success: true, running: true }); + } catch (err: any) { + res.status(500).json({ error: "Failed to start rclone server", details: err.message }); + } +}); + +router.post("/rclone-server/stop", (req: Request, res: Response): any => { + if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); + stopWebDavServer(); + res.json({ success: true, running: false }); +}); + +router.post("/rclone-pull-now", async (req: Request, res: Response): Promise => { + if (!req.user?.id) return res.status(401).json({ error: "Unauthorized" }); + + try { + await runRclonePull(); + writeRcloneConfig({ lastSyncAt: new Date().toISOString(), lastSyncStatus: "success", lastSyncError: null }); + res.json({ success: true, syncedAt: new Date() }); + } catch (err: any) { + writeRcloneConfig({ lastSyncAt: new Date().toISOString(), lastSyncStatus: "failed", lastSyncError: err.message }); + res.status(500).json({ error: "Rclone pull failed", details: err.message }); + } +}); + export default router; diff --git a/apps/Backend/src/services/rcloneConfigService.ts b/apps/Backend/src/services/rcloneConfigService.ts new file mode 100644 index 00000000..79ee5cfc --- /dev/null +++ b/apps/Backend/src/services/rcloneConfigService.ts @@ -0,0 +1,50 @@ +import fs from "fs"; +import path from "path"; + +const CONFIG_FILE = path.resolve(process.cwd(), "rclone-config.json"); + +export const RCLONE_USER = "MyDentalApp"; +export const RCLONE_PASS = "SuperSecret!@2026"; + +export interface RcloneConfig { + // Source role: serve backups folder via WebDAV + serverEnabled: boolean; + serverPort: number; + + // Receiver role: pull backups from source PC + receiverEnabled: boolean; + receiverSyncHour: number; + sourceIp: string; + sourcePort: number; + lastSyncAt: string | null; + lastSyncStatus: string | null; + lastSyncError: string | null; +} + +const DEFAULT_CONFIG: RcloneConfig = { + serverEnabled: false, + serverPort: 8080, + receiverEnabled: false, + receiverSyncHour: 21, + sourceIp: "", + sourcePort: 8080, + lastSyncAt: null, + lastSyncStatus: null, + lastSyncError: null, +}; + +export function readRcloneConfig(): RcloneConfig { + if (fs.existsSync(CONFIG_FILE)) { + try { + return { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")) }; + } catch {} + } + return { ...DEFAULT_CONFIG }; +} + +export function writeRcloneConfig(patch: Partial): RcloneConfig { + const current = readRcloneConfig(); + const updated = { ...current, ...patch }; + fs.writeFileSync(CONFIG_FILE, JSON.stringify(updated, null, 2), "utf8"); + return updated; +} diff --git a/apps/Backend/src/services/rcloneService.ts b/apps/Backend/src/services/rcloneService.ts new file mode 100644 index 00000000..6993c697 --- /dev/null +++ b/apps/Backend/src/services/rcloneService.ts @@ -0,0 +1,140 @@ +import { spawn, ChildProcess } from "child_process"; +import path from "path"; +import { readRcloneConfig, RCLONE_USER, RCLONE_PASS } from "./rcloneConfigService"; + +const LOCAL_BACKUP_DIR = path.resolve(process.cwd(), "backups"); + +let serverProcess: ChildProcess | null = null; + +export function checkRcloneInstalled(): Promise { + return new Promise((resolve) => { + const proc = spawn("rclone", ["version"]); + proc.on("error", () => resolve(false)); + proc.on("close", (code) => resolve(code === 0)); + }); +} + +function obscurePassword(password: string): Promise { + return new Promise((resolve, reject) => { + const proc = spawn("rclone", ["obscure", password]); + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (d) => (stdout += d.toString())); + proc.stderr.on("data", (d) => (stderr += d.toString())); + proc.on("error", (err) => reject(new Error(`Failed to run rclone obscure: ${err.message}`))); + proc.on("close", (code) => { + if (code !== 0) return reject(new Error(`rclone obscure failed: ${stderr}`)); + resolve(stdout.trim()); + }); + }); +} + +export function isServerRunning(): boolean { + return serverProcess !== null && serverProcess.exitCode === null; +} + +// Source PC: rclone serve webdav ./backups --addr :8080 --user MyDentalApp --pass SuperSecret!@2026 +export async function startWebDavServer(): Promise { + if (isServerRunning()) return; + + const installed = await checkRcloneInstalled(); + if (!installed) { + throw new Error("rclone is not installed. Run: curl https://rclone.org/install.sh | sudo bash"); + } + + const config = readRcloneConfig(); + + const args = [ + "serve", "webdav", + LOCAL_BACKUP_DIR, + "--addr", `:${config.serverPort}`, + "--user", RCLONE_USER, + "--pass", RCLONE_PASS, + "--read-only", + ]; + + serverProcess = spawn("rclone", args, { stdio: "pipe" }); + + serverProcess.stdout?.on("data", (d) => console.log(`[rclone-server] ${d.toString().trim()}`)); + serverProcess.stderr?.on("data", (d) => console.log(`[rclone-server] ${d.toString().trim()}`)); + + serverProcess.on("error", (err) => { + console.error("[rclone-server] Failed to start:", err.message); + serverProcess = null; + }); + + serverProcess.on("close", (code) => { + console.log(`[rclone-server] Exited with code ${code}`); + serverProcess = null; + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + if (!isServerRunning()) { + throw new Error("rclone WebDAV server failed to start"); + } + + console.log(`[rclone-server] WebDAV server started on port ${config.serverPort}`); +} + +export function stopWebDavServer(): void { + if (serverProcess && serverProcess.exitCode === null) { + serverProcess.kill("SIGTERM"); + serverProcess = null; + console.log("[rclone-server] WebDAV server stopped"); + } +} + +// Receiver PC: rclone sync :webdav:/ ./backups --webdav-url http://IP:PORT --webdav-user MyDentalApp --webdav-pass OBSCURED +export async function runRclonePull(): Promise { + const config = readRcloneConfig(); + if (!config.sourceIp || !config.sourcePort) { + throw new Error("Rclone receiver configuration is incomplete — source IP and port are required"); + } + + const installed = await checkRcloneInstalled(); + if (!installed) { + throw new Error("rclone is not installed. Run: curl https://rclone.org/install.sh | sudo bash"); + } + + const obscuredPass = await obscurePassword(RCLONE_PASS); + const webdavUrl = `http://${config.sourceIp}:${config.sourcePort}`; + + const args = [ + "sync", + `:webdav:/`, + LOCAL_BACKUP_DIR, + "--webdav-url", webdavUrl, + "--webdav-user", RCLONE_USER, + "--webdav-pass", obscuredPass, + "-v", + ]; + + return new Promise((resolve, reject) => { + const proc = spawn("rclone", args); + + let stderr = ""; + proc.stdout.on("data", (d) => console.log(`[rclone-pull] ${d.toString().trim()}`)); + proc.stderr.on("data", (d) => { + const line = d.toString().trim(); + stderr += line + "\n"; + console.log(`[rclone-pull] ${line}`); + }); + proc.on("error", (err) => reject(new Error(`Failed to start rclone: ${err.message}`))); + proc.on("close", (code) => { + if (code !== 0) return reject(new Error(`rclone exited with code ${code}: ${stderr}`)); + resolve(); + }); + }); +} + +export async function autoStartServer(): Promise { + const config = readRcloneConfig(); + if (config.serverEnabled) { + try { + await startWebDavServer(); + } catch (err) { + console.error("[rclone-server] Auto-start failed:", err); + } + } +} diff --git a/apps/Frontend/src/components/database-management/network-backup-manager.tsx b/apps/Frontend/src/components/database-management/network-backup-manager.tsx index 42802d00..4d7b839e 100644 --- a/apps/Frontend/src/components/database-management/network-backup-manager.tsx +++ b/apps/Frontend/src/components/database-management/network-backup-manager.tsx @@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { AlertDialog, AlertDialogAction, @@ -14,7 +15,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { Copy, Eye, EyeOff, RefreshCw, Network, RotateCcw } from "lucide-react"; +import { Copy, Eye, EyeOff, RefreshCw, Network, RotateCcw, HardDrive } from "lucide-react"; import { apiRequest, queryClient } from "@/lib/queryClient"; import { useToast } from "@/hooks/use-toast"; @@ -31,21 +32,295 @@ const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => { }); export function NetworkBackupManager() { + return ( + + + + + Network Backup + + + + + + + + Rclone + + + + API Key + + + + + + + + + + + + ); +} + +// ============================================================ +// Tab 1: Rclone +// ============================================================ +function RcloneBackupSection() { + const { toast } = useToast(); + + // Server (source) state + const [serverEnabled, setServerEnabled] = useState(false); + const [serverPort, setServerPort] = useState(8080); + + // Receiver state + const [receiverEnabled, setReceiverEnabled] = useState(false); + const [receiverSyncHour, setReceiverSyncHour] = useState(21); + const [sourceIp, setSourceIp] = useState(""); + const [sourcePort, setSourcePort] = useState(8080); + + const [formLoaded, setFormLoaded] = useState(false); + + const { data: rcloneConfig } = useQuery({ + queryKey: ["/db/rclone-config"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/database-management/rclone-config"); + return res.json(); + }, + }); + + const { data: rcloneStatus } = useQuery({ + queryKey: ["/db/rclone-status"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/database-management/rclone-status"); + return res.json() as Promise<{ installed: boolean; serverRunning: boolean }>; + }, + refetchInterval: 5000, + }); + + useEffect(() => { + if (rcloneConfig && !formLoaded) { + setServerEnabled(rcloneConfig.serverEnabled ?? false); + setServerPort(rcloneConfig.serverPort ?? 8080); + setReceiverEnabled(rcloneConfig.receiverEnabled ?? false); + setReceiverSyncHour(rcloneConfig.receiverSyncHour ?? 21); + setSourceIp(rcloneConfig.sourceIp ?? ""); + setSourcePort(rcloneConfig.sourcePort ?? 8080); + setFormLoaded(true); + } + }, [rcloneConfig, formLoaded]); + + const saveMutation = useMutation({ + mutationFn: async () => { + const res = await apiRequest("PUT", "/api/database-management/rclone-config", { + serverEnabled, + serverPort, + receiverEnabled, + receiverSyncHour, + sourceIp, + sourcePort, + }); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/db/rclone-config"] }); + toast({ title: "Rclone settings saved" }); + }, + onError: () => { + toast({ title: "Error", description: "Failed to save rclone settings.", variant: "destructive" }); + }, + }); + + const pullNowMutation = useMutation({ + mutationFn: async () => { + const res = await apiRequest("POST", "/api/database-management/rclone-pull-now"); + if (!res.ok) { + const body = await res.json(); + throw new Error(body.details || body.error || "Pull failed"); + } + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/db/rclone-config"] }); + toast({ title: "Rclone pull complete", description: "Backup files copied from source PC." }); + }, + onError: (err: any) => { + queryClient.invalidateQueries({ queryKey: ["/db/rclone-config"] }); + toast({ title: "Rclone pull failed", description: err.message, variant: "destructive" }); + }, + }); + + const serverRunning = rcloneStatus?.serverRunning ?? false; + + return ( +
+ {/* ── Source PC: Serve backups via WebDAV ── */} +
+
+

Source PC — Serve Backups

+
+
+ {serverRunning ? "Running" : "Stopped"} +
+
+

+ Enable this on the source PC to serve the backups/ folder via WebDAV. + The backup PC will connect to this machine to pull files. +

+ +
+
+ + +
+
+ +
+ + setServerPort(Number(e.target.value))} + /> +
+ +
+ + {/* ── Receiver PC: Pull backups from source ── */} +
+

Receiver PC — Pull Backups

+

+ Enable this on the backup PC to pull backup files from the source PC's WebDAV server + at a scheduled time each day. +

+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + setSourceIp(e.target.value)} + /> +
+
+ + setSourcePort(Number(e.target.value))} + /> +
+
+ +
+ +
+ + +
+ + {/* Save all settings */} + +
+ ); +} + +function RcloneSyncStatus() { + const { data } = useQuery({ + queryKey: ["/db/rclone-config"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/database-management/rclone-config"); + return res.json(); + }, + }); + + if (!data?.lastSyncAt) return null; + + const date = new Date(data.lastSyncAt).toLocaleString(); + const ok = data.lastSyncStatus === "success"; + + return ( +
+ {ok ? "Last pull: " : "Last pull failed: "}{date} + {!ok && data.lastSyncError && ( + {data.lastSyncError} + )} +
+ ); +} + +// ============================================================ +// Tab 2: API Key (existing behavior) +// ============================================================ +function ApiKeyBackupSection() { const { toast } = useToast(); const [showKey, setShowKey] = useState(false); const [showReceiverKey, setShowReceiverKey] = useState(false); const [confirmRegenOpen, setConfirmRegenOpen] = useState(false); - // receiver form state (initialised from query) const [enabled, setEnabled] = useState(false); const [syncHour, setSyncHour] = useState(0); const [sourceUrl, setSourceUrl] = useState(""); const [receiverApiKey, setReceiverApiKey] = useState(""); const [formLoaded, setFormLoaded] = useState(false); - // ============================== - // Queries - // ============================== const { data: keyData } = useQuery({ queryKey: ["/db/network-backup-key"], queryFn: async () => { @@ -72,9 +347,6 @@ export function NetworkBackupManager() { } }, [syncConfig, formLoaded]); - // ============================== - // Mutations - // ============================== const regenMutation = useMutation({ mutationFn: async () => { const res = await apiRequest("POST", "/api/database-management/network-backup-key/regenerate"); @@ -132,155 +404,144 @@ export function NetworkBackupManager() { const maskedKey = displayKey ? "••••••••-••••-••••-••••-" + displayKey.slice(-12) : "—"; return ( - - - - - Network Backup - - - +
+ {/* Source role */} +
+

This Machine's Backup Key

+

+ Share this key with the backup PC so it can pull a copy of this machine's + database. The key survives database restores (stored in a local file). +

+
+ + + + +
+
- {/* ── Section A: Source role (this machine's API key) ── */} -
-

This Machine's Backup Key

-

- Share this key with the backup PC so it can pull a copy of this machine's - database. The key survives database restores (stored in a local file). -

+
+ + {/* Receiver role */} +
+

Sync from Another PC

+

+ Configure this machine to pull a fresh copy of the database and all uploaded + files (patient photos, cloud storage, documents) from another PC at a scheduled + time each day. Enter the source PC's URL (e.g. http://192.168.0.94 — no port number) and the Backup Key shown in the source PC's Network Backup section. +

+ +
+
+ + +
+ +
+ + +
+
+ +
+ + setSourceUrl(e.target.value)} + /> +
+ +
+
setReceiverApiKey(e.target.value)} /> - -
-
- - {/* ── Section B: Receiver role (pull config) ── */} -
-

Sync from Another PC

-

- Configure this machine to pull a fresh copy of the database and all uploaded - files (patient photos, cloud storage, documents) from another PC at a scheduled - time each day. Enter the source PC's URL (e.g. http://192.168.0.94 — no port number) and the Backup Key shown in the source PC's Network Backup section. -

- - {/* Enable toggle + time picker on same row */} -
-
- - -
- -
- - -
-
- -
- - setSourceUrl(e.target.value)} - /> -
- -
- -
- setReceiverApiKey(e.target.value)} - /> - -
-
- -
- - -
- - {/* Last sync status */} - +
+ +
- + + +
{/* Confirm regenerate dialog */} @@ -303,11 +564,11 @@ export function NetworkBackupManager() { - +
); } -function SyncStatus() { +function ApiKeySyncStatus() { const { data } = useQuery({ queryKey: ["/db/network-sync-config"], queryFn: async () => { @@ -323,7 +584,7 @@ function SyncStatus() { return (
- {ok ? "✅" : "❌"} Last sync: {date} + {ok ? "Last sync: " : "Last sync failed: "}{date} {!ok && data.lastSyncError && ( {data.lastSyncError} )} diff --git a/package.json b/package.json index cfa969e7..db516e94 100755 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "db:seed": "npx ts-node packages/db/prisma/seed.ts", "db:studio": "npx prisma studio --config=packages/db/prisma/prisma.config.ts", "setup:env": "shx cp packages/db/prisma/.env.example packages/db/prisma/.env && shx cp apps/Frontend/.env.example apps/Frontend/.env && shx cp apps/Backend/.env.example apps/Backend/.env && shx cp apps/PatientDataExtractorService/.env.example apps/PatientDataExtractorService/.env && shx cp apps/SeleniumService/.env.example apps/SeleniumService/.env && shx cp apps/PaymentOCRService/.env.example apps/PaymentOCRService/.env", - "postinstall": "npm --prefix apps/PatientDataExtractorService run postinstall && npm --prefix apps/PaymentOCRService run postinstall" + "postinstall": "npm --prefix apps/PatientDataExtractorService run postinstall && npm --prefix apps/PaymentOCRService run postinstall && bash scripts/install-rclone.sh" }, "prisma": { "seed": "ts-node packages/db/prisma/seed.ts" diff --git a/scripts/install-rclone.sh b/scripts/install-rclone.sh new file mode 100755 index 00000000..897b7767 --- /dev/null +++ b/scripts/install-rclone.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Install rclone if not already present +if command -v rclone &> /dev/null; then + echo "rclone is already installed: $(rclone version | head -1)" + exit 0 +fi + +echo "Installing rclone..." +if command -v curl &> /dev/null; then + curl -s https://rclone.org/install.sh | sudo bash +elif command -v wget &> /dev/null; then + wget -qO- https://rclone.org/install.sh | sudo bash +else + echo "WARNING: Neither curl nor wget found. Please install rclone manually:" + echo " https://rclone.org/install/" + exit 0 +fi + +if command -v rclone &> /dev/null; then + echo "rclone installed successfully: $(rclone version | head -1)" +else + echo "WARNING: rclone installation may have failed. Please install manually." +fi