| #!/usr/bin/env node |
| import crypto from "node:crypto"; |
| import fs from "node:fs"; |
| import path from "node:path"; |
|
|
| const home = process.env.OPENCLAW_HOME || process.env.HOME || "/home/user"; |
| const stateDir = path.join(home, ".openclaw"); |
| const supabaseUrl = process.env.SUPABASE_URL?.trim(); |
| const supabaseKey = process.env.SUPABASE_KEY?.trim(); |
| const tableName = process.env.OPENCLAW_SUPABASE_TABLE?.trim() || "openclaw_state"; |
| const intervalMs = Number(process.env.OPENCLAW_SYNC_INTERVAL_MS || 300000); |
| const maxFileBytes = Number(process.env.OPENCLAW_SYNC_MAX_FILE_BYTES || 5 * 1024 * 1024); |
| const includeGlobs = (process.env.OPENCLAW_SYNC_INCLUDE_EXTENSIONS || ".json,.jsonl,.md,.txt") |
| .split(",") |
| .map((value) => value.trim().toLowerCase()) |
| .filter(Boolean); |
|
|
| if (!supabaseUrl || !supabaseKey) { |
| console.error("[openclaw-sync] SUPABASE_URL or SUPABASE_KEY is missing"); |
| process.exit(1); |
| } |
|
|
| const seenHashes = new Map(); |
|
|
| function shouldSyncFile(filePath, stats) { |
| if (!stats.isFile()) return false; |
| if (stats.size > maxFileBytes) return false; |
| const normalized = filePath.toLowerCase(); |
| return includeGlobs.some((ext) => normalized.endsWith(ext)); |
| } |
|
|
| function listFilesRecursive(rootDir) { |
| if (!fs.existsSync(rootDir)) return []; |
| const results = []; |
| const stack = [rootDir]; |
|
|
| while (stack.length > 0) { |
| const currentDir = stack.pop(); |
| const entries = fs.readdirSync(currentDir, { withFileTypes: true }); |
| for (const entry of entries) { |
| const fullPath = path.join(currentDir, entry.name); |
| if (entry.isDirectory()) { |
| stack.push(fullPath); |
| continue; |
| } |
| const stats = fs.statSync(fullPath); |
| if (shouldSyncFile(fullPath, stats)) { |
| results.push({ fullPath, stats }); |
| } |
| } |
| } |
|
|
| return results.sort((a, b) => a.fullPath.localeCompare(b.fullPath)); |
| } |
|
|
| function sha256(content) { |
| return crypto.createHash("sha256").update(content).digest("hex"); |
| } |
|
|
| function classifyPath(relativePath) { |
| if (relativePath === "openclaw.json") return "config"; |
| if (relativePath.includes("sessions")) return "session"; |
| if (relativePath.includes("memory")) return "memory"; |
| if (relativePath.includes("logs")) return "log"; |
| return "state"; |
| } |
|
|
| async function upsertRows(rows) { |
| const endpoint = `${supabaseUrl.replace(/\/$/, "")}/rest/v1/${tableName}?on_conflict=path`; |
| const response = await fetch(endpoint, { |
| method: "POST", |
| headers: { |
| apikey: supabaseKey, |
| Authorization: `Bearer ${supabaseKey}`, |
| "Content-Type": "application/json", |
| Prefer: "resolution=merge-duplicates", |
| }, |
| body: JSON.stringify(rows), |
| }); |
|
|
| if (!response.ok) { |
| const body = await response.text(); |
| throw new Error(`Supabase upsert failed (${response.status}): ${body}`); |
| } |
| } |
|
|
| async function syncOnce() { |
| const files = listFilesRecursive(stateDir); |
| const changedRows = []; |
|
|
| for (const { fullPath, stats } of files) { |
| const relativePath = path.relative(stateDir, fullPath).replaceAll("\\", "/"); |
| const content = fs.readFileSync(fullPath, "utf-8"); |
| const hash = sha256(content); |
| if (seenHashes.get(relativePath) === hash) continue; |
|
|
| seenHashes.set(relativePath, hash); |
| changedRows.push({ |
| path: relativePath, |
| kind: classifyPath(relativePath), |
| content, |
| sha256: hash, |
| size_bytes: stats.size, |
| updated_at: new Date(stats.mtimeMs).toISOString(), |
| synced_at: new Date().toISOString(), |
| }); |
| } |
|
|
| if (changedRows.length === 0) { |
| console.log("[openclaw-sync] no changes"); |
| return; |
| } |
|
|
| for (let index = 0; index < changedRows.length; index += 25) { |
| const batch = changedRows.slice(index, index + 25); |
| await upsertRows(batch); |
| } |
|
|
| console.log(`[openclaw-sync] synced ${changedRows.length} file(s) from ${stateDir}`); |
| } |
|
|
| async function main() { |
| console.log( |
| `[openclaw-sync] provider=supabase table=${tableName} interval_ms=${intervalMs} state_dir=${stateDir}`, |
| ); |
|
|
| while (true) { |
| try { |
| await syncOnce(); |
| } catch (error) { |
| console.error(`[openclaw-sync] ${error instanceof Error ? error.message : String(error)}`); |
| } |
| await new Promise((resolve) => setTimeout(resolve, intervalMs)); |
| } |
| } |
|
|
| main().catch((error) => { |
| console.error(`[openclaw-sync] fatal ${error instanceof Error ? error.stack : String(error)}`); |
| process.exit(1); |
| }); |
|
|