diff --git a/README.md b/README.md index 9033afe..b558be3 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Deploy to Render

-> **Platform:** AlphaClaw currently targets Docker/Linux deployments. macOS local development is not yet supported. +> **Platform:** AlphaClaw supports Docker/Linux deployments and local macOS development. Linux installs the hourly git sync via `/etc/cron.d`; macOS runs the sync schedule from AlphaClaw's managed in-process scheduler. ## Features diff --git a/bin/alphaclaw.js b/bin/alphaclaw.js index 186ca40..85c81f0 100755 --- a/bin/alphaclaw.js +++ b/bin/alphaclaw.js @@ -9,10 +9,15 @@ const { normalizeGitSyncFilePath, validateGitSyncFilePath, } = require("../lib/cli/git-sync"); -const { buildSecretReplacements } = require("../lib/server/helpers"); const { migrateManagedInternalFiles, } = require("../lib/server/internal-files-migration"); +const { + applySystemCronConfig, + normalizeCronPlatform, + readSystemCronConfig, + startManagedScheduler, +} = require("../lib/server/system-cron"); const kUsageTrackerPluginPath = path.resolve( __dirname, @@ -21,6 +26,76 @@ const kUsageTrackerPluginPath = path.resolve( "plugin", "usage-tracker", ); +const kSystemBinDir = "/usr/local/bin"; + +const prependPathEntry = (entryPath) => { + const currentPath = String(process.env.PATH || ""); + const entries = currentPath + .split(path.delimiter) + .map((value) => value.trim()) + .filter(Boolean); + if (entries.includes(entryPath)) return; + process.env.PATH = [entryPath, ...entries].join(path.delimiter); +}; + +const isWritableDirectory = (dirPath) => { + try { + fs.accessSync(dirPath, fs.constants.W_OK); + return true; + } catch { + return false; + } +}; + +const findFileRecursive = (rootPath, fileName) => { + const pending = [rootPath]; + while (pending.length) { + const currentPath = pending.pop(); + let entries = []; + try { + entries = fs.readdirSync(currentPath, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const entryPath = path.join(currentPath, entry.name); + if (entry.isFile() && entry.name === fileName) return entryPath; + if (entry.isDirectory()) pending.push(entryPath); + } + } + return ""; +}; + +const installTarballBinary = ({ + url, + binaryName, + installDir, + logLabel, +}) => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "alphaclaw-bin-")); + const tarballPath = path.join(tempDir, `${binaryName}.tar.gz`); + try { + execSync(`curl -fsSL "${url}" -o ${quoteArg(tarballPath)}`, { + stdio: "inherit", + }); + execSync(`tar -xzf ${quoteArg(tarballPath)} -C ${quoteArg(tempDir)}`, { + stdio: "inherit", + }); + const extractedBinaryPath = findFileRecursive(tempDir, binaryName); + if (!extractedBinaryPath) { + throw new Error(`Could not find ${binaryName} in downloaded archive`); + } + const targetPath = path.join(installDir, binaryName); + fs.copyFileSync(extractedBinaryPath, targetPath); + fs.chmodSync(targetPath, 0o755); + console.log(`[alphaclaw] ${logLabel} installed at ${targetPath}`); + return targetPath; + } finally { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch {} + } +}; // --------------------------------------------------------------------------- // Parse CLI flags @@ -146,10 +221,16 @@ if (portFlag) { const openclawDir = path.join(rootDir, ".openclaw"); fs.mkdirSync(openclawDir, { recursive: true }); -const { hourlyGitSyncPath } = migrateManagedInternalFiles({ +const { hourlyGitSyncPath, internalDir } = migrateManagedInternalFiles({ fs, openclawDir, }); +const managedBinDir = path.join(internalDir, "bin"); +fs.mkdirSync(managedBinDir, { recursive: true }); +prependPathEntry(managedBinDir); +const installBinDir = isWritableDirectory(kSystemBinDir) + ? kSystemBinDir + : managedBinDir; console.log(`[alphaclaw] Root directory: ${rootDir}`); // Check for pending update marker (written by the update endpoint before restart). @@ -233,6 +314,8 @@ if (fs.existsSync(envFilePath)) { console.log("[alphaclaw] Loaded .env"); } +const { buildSecretReplacements } = require("../lib/server/helpers"); + const runGitSync = () => { const githubToken = String(process.env.GITHUB_TOKEN || "").trim(); const githubRepo = resolveGithubRepoPath( @@ -507,11 +590,12 @@ if (!gogInstalled) { const arch = os.arch() === "arm64" ? "arm64" : "amd64"; const tarball = `gogcli_${gogVersion}_${platform}_${arch}.tar.gz`; const url = `https://github.com/steipete/gogcli/releases/download/v${gogVersion}/${tarball}`; - execSync( - `curl -fsSL "${url}" -o /tmp/gog.tar.gz && tar -xzf /tmp/gog.tar.gz -C /tmp/ && mv /tmp/gog /usr/local/bin/gog && chmod +x /usr/local/bin/gog && rm -f /tmp/gog.tar.gz`, - { stdio: "inherit" }, - ); - console.log("[alphaclaw] gog CLI installed"); + installTarballBinary({ + url, + binaryName: "gog", + installDir: installBinDir, + logLabel: "gog CLI", + }); } catch (e) { console.log(`[alphaclaw] gog install skipped: ${e.message}`); } @@ -549,35 +633,25 @@ try { if (fs.existsSync(hourlyGitSyncPath)) { try { - const syncCronConfig = path.join(openclawDir, "cron", "system-sync.json"); - let cronEnabled = true; - let cronSchedule = "0 * * * *"; - - if (fs.existsSync(syncCronConfig)) { - try { - const cfg = JSON.parse(fs.readFileSync(syncCronConfig, "utf8")); - cronEnabled = cfg.enabled !== false; - const schedule = String(cfg.schedule || "").trim(); - if (/^(\S+\s+){4}\S+$/.test(schedule)) cronSchedule = schedule; - } catch {} - } - - const cronFilePath = "/etc/cron.d/openclaw-hourly-sync"; - if (cronEnabled) { - const cronContent = [ - "SHELL=/bin/bash", - "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", - `${cronSchedule} root bash "${hourlyGitSyncPath}" >> /var/log/openclaw-hourly-sync.log 2>&1`, - "", - ].join("\n"); - fs.writeFileSync(cronFilePath, cronContent, { mode: 0o644 }); - console.log("[alphaclaw] System cron entry installed"); - } else { - try { - fs.unlinkSync(cronFilePath); - } catch {} - console.log("[alphaclaw] System cron entry disabled"); - } + startManagedScheduler({ + fs, + openclawDir, + platform: process.platform, + }); + const cronConfig = readSystemCronConfig({ + fs, + openclawDir, + platform: process.platform, + }); + const cronStatus = applySystemCronConfig({ + fs, + openclawDir, + nextConfig: cronConfig, + platform: process.platform, + }); + console.log( + `[alphaclaw] System cron ${cronStatus.enabled ? "configured" : "disabled"} (${cronStatus.installMethod})`, + ); } catch (e) { console.log(`[alphaclaw] Cron setup skipped: ${e.message}`); } @@ -587,15 +661,17 @@ if (fs.existsSync(hourlyGitSyncPath)) { // 9. Start cron daemon if available // --------------------------------------------------------------------------- -try { - execSync("command -v cron", { stdio: "ignore" }); +if (normalizeCronPlatform(process.platform) !== "darwin") { try { - execSync("pgrep -x cron", { stdio: "ignore" }); - } catch { - execSync("cron", { stdio: "ignore" }); - } - console.log("[alphaclaw] Cron daemon running"); -} catch {} + execSync("command -v cron", { stdio: "ignore" }); + try { + execSync("pgrep -x cron", { stdio: "ignore" }); + } catch { + execSync("cron", { stdio: "ignore" }); + } + console.log("[alphaclaw] Cron daemon running"); + } catch {} +} // --------------------------------------------------------------------------- // 10. Reconcile channels if already onboarded @@ -821,11 +897,11 @@ try { execSync("command -v systemctl", { stdio: "ignore" }); } catch { const shimSrc = path.join(__dirname, "..", "lib", "scripts", "systemctl"); - const shimDest = "/usr/local/bin/systemctl"; + const shimDest = path.join(installBinDir, "systemctl"); try { fs.copyFileSync(shimSrc, shimDest); fs.chmodSync(shimDest, 0o755); - console.log("[alphaclaw] systemctl shim installed"); + console.log(`[alphaclaw] systemctl shim installed at ${shimDest}`); } catch (e) { console.log(`[alphaclaw] systemctl shim skipped: ${e.message}`); } @@ -837,9 +913,9 @@ try { try { const gitAskPassSrc = path.join(__dirname, "..", "lib", "scripts", "git-askpass"); - const gitAskPassDest = "/tmp/alphaclaw-git-askpass.sh"; + const gitAskPassDest = path.join(managedBinDir, "alphaclaw-git-askpass.sh"); const gitShimTemplatePath = path.join(__dirname, "..", "lib", "scripts", "git"); - const gitShimDest = "/usr/local/bin/git"; + const gitShimDest = path.join(installBinDir, "git"); if (fs.existsSync(gitAskPassSrc)) { fs.copyFileSync(gitAskPassSrc, gitAskPassDest); @@ -870,7 +946,7 @@ try { .replace("@@REAL_GIT@@", realGitPath) .replace("@@OPENCLAW_REPO_ROOT@@", openclawDir); fs.writeFileSync(gitShimDest, gitShimContent, { mode: 0o755 }); - console.log("[alphaclaw] git auth shim installed"); + console.log(`[alphaclaw] git auth shim installed at ${gitShimDest}`); } } catch (e) { console.log(`[alphaclaw] git auth shim skipped: ${e.message}`); diff --git a/lib/server/onboarding/cron.js b/lib/server/onboarding/cron.js index 235c3d9..24da4a8 100644 --- a/lib/server/onboarding/cron.js +++ b/lib/server/onboarding/cron.js @@ -1,20 +1,14 @@ +const os = require("os"); const path = require("path"); const { kSetupDir } = require("../constants"); const { buildManagedPaths } = require("../internal-files-migration"); +const { + applySystemCronConfig, + getSystemCronPaths, + kDefaultSystemCronSchedule, +} = require("../system-cron"); const kHourlyGitSyncTemplatePath = path.join(kSetupDir, "hourly-git-sync.sh"); -const kSystemCronPath = "/etc/cron.d/openclaw-hourly-sync"; -const kSystemCronConfigDir = "cron"; -const kSystemCronConfigFile = "system-sync.json"; -const kDefaultSystemCronSchedule = "0 * * * *"; - -const buildSystemCronFile = ({ schedule, scriptPath }) => - [ - "SHELL=/bin/bash", - "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", - `${schedule} root bash "${scriptPath}" >> /var/log/openclaw-hourly-sync.log 2>&1`, - "", - ].join("\n"); const installHourlyGitSyncScript = ({ fs, openclawDir }) => { try { @@ -28,22 +22,28 @@ const installHourlyGitSyncScript = ({ fs, openclawDir }) => { } }; -const installHourlyGitSyncCron = async ({ fs, openclawDir }) => { +const installHourlyGitSyncCron = async ({ + fs, + openclawDir, + platform = os.platform(), + execFileSyncImpl, +}) => { try { - const { hourlyGitSyncPath } = buildManagedPaths({ openclawDir }); - const configDir = `${openclawDir}/${kSystemCronConfigDir}`; - const configPath = `${configDir}/${kSystemCronConfigFile}`; - const config = { enabled: true, schedule: kDefaultSystemCronSchedule }; - fs.mkdirSync(configDir, { recursive: true }); - fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); - - const cronContent = buildSystemCronFile({ - schedule: config.schedule, - scriptPath: hourlyGitSyncPath, + const paths = getSystemCronPaths({ openclawDir, platform }); + const status = applySystemCronConfig({ + fs, + openclawDir, + nextConfig: { + enabled: true, + schedule: kDefaultSystemCronSchedule, + }, + platform, + execFileSyncImpl, }); - fs.writeFileSync(kSystemCronPath, cronContent, { mode: 0o644 }); - console.log(`[onboard] Installed system cron job at ${kSystemCronPath} (${configPath})`); - return true; + console.log( + `[onboard] Installed system cron job at ${paths.installPath} (${paths.configPath})`, + ); + return status.installed; } catch (e) { console.error("[onboard] System cron install error:", e.message); return false; diff --git a/lib/server/onboarding/index.js b/lib/server/onboarding/index.js index 51d9b63..6ad4291 100644 --- a/lib/server/onboarding/index.js +++ b/lib/server/onboarding/index.js @@ -294,6 +294,8 @@ const createOnboardingService = ({ ensureGatewayProxyConfig, getBaseUrl, startGateway, + platform = process.platform, + execFileSyncImpl, }) => { const { OPENCLAW_DIR, WORKSPACE_DIR, kOnboardingMarkerPath } = constants; @@ -506,7 +508,12 @@ const createOnboardingService = ({ installGogCliSkill({ fs, openclawDir: OPENCLAW_DIR }); installHourlyGitSyncScript({ fs, openclawDir: OPENCLAW_DIR }); - await installHourlyGitSyncCron({ fs, openclawDir: OPENCLAW_DIR }); + await installHourlyGitSyncCron({ + fs, + openclawDir: OPENCLAW_DIR, + platform, + execFileSyncImpl, + }); fs.mkdirSync(path.dirname(kOnboardingMarkerPath), { recursive: true }); fs.writeFileSync( kOnboardingMarkerPath, diff --git a/lib/server/routes/onboarding.js b/lib/server/routes/onboarding.js index e414fb5..413e935 100644 --- a/lib/server/routes/onboarding.js +++ b/lib/server/routes/onboarding.js @@ -1,3 +1,4 @@ +const { execFileSync } = require("child_process"); const { createOnboardingService, getImportedPlaceholderReview, @@ -94,6 +95,8 @@ const registerOnboardingRoutes = ({ ensureGatewayProxyConfig, getBaseUrl, startGateway, + platform = process.platform, + execFileSyncImpl = execFileSync, }) => { // Keep mutating onboarding routes marker-gated so in-progress imports // can promote files before the final completion marker is written. @@ -115,6 +118,8 @@ const registerOnboardingRoutes = ({ ensureGatewayProxyConfig, getBaseUrl, startGateway, + platform, + execFileSyncImpl, }); const kEnvVarNamePattern = /^[A-Z_][A-Z0-9_]*$/; diff --git a/lib/server/routes/system.js b/lib/server/routes/system.js index 0b89bf3..00d5c57 100644 --- a/lib/server/routes/system.js +++ b/lib/server/routes/system.js @@ -1,4 +1,9 @@ -const { buildManagedPaths } = require("../internal-files-migration"); +const { execFileSync } = require("child_process"); +const { + applySystemCronConfig, + getSystemCronStatus, + isValidCronSchedule, +} = require("../system-cron"); const registerSystemRoutes = ({ app, @@ -21,6 +26,8 @@ const registerSystemRoutes = ({ restartRequiredState, topicRegistry, authProfiles, + platform = process.platform, + execFileSyncImpl = execFileSync, }) => { let envRestartPending = false; const kEnvVarsReservedForUserInput = new Set([ @@ -36,21 +43,6 @@ const registerSystemRoutes = ({ ); const isReservedUserEnvVar = (key) => kSystemVars.has(key) || kEnvVarsReservedForUserInput.has(key); - const kSystemCronPath = "/etc/cron.d/openclaw-hourly-sync"; - const kSystemCronConfigPath = `${OPENCLAW_DIR}/cron/system-sync.json`; - const { hourlyGitSyncPath: kSystemCronScriptPath } = buildManagedPaths({ - openclawDir: OPENCLAW_DIR, - }); - const kDefaultSystemCronSchedule = "0 * * * *"; - const isValidCronSchedule = (value) => - typeof value === "string" && /^(\S+\s+){4}\S+$/.test(value.trim()); - const buildSystemCronContent = (schedule) => - [ - "SHELL=/bin/bash", - "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", - `${schedule} root bash "${kSystemCronScriptPath}" >> /var/log/openclaw-hourly-sync.log 2>&1`, - "", - ].join("\n"); const shellEscapeArg = (value) => { const safeValue = String(value || ""); return `'${safeValue.replace(/'/g, `'\\''`)}'`; @@ -177,47 +169,21 @@ const registerSystemRoutes = ({ }) .sort((a, b) => b.updatedAt - a.updatedAt); }; - const readSystemCronConfig = () => { - try { - const raw = fs.readFileSync(kSystemCronConfigPath, "utf8"); - const parsed = JSON.parse(raw); - const enabled = parsed.enabled !== false; - const schedule = isValidCronSchedule(parsed.schedule) - ? parsed.schedule.trim() - : kDefaultSystemCronSchedule; - return { enabled, schedule }; - } catch { - return { enabled: true, schedule: kDefaultSystemCronSchedule }; - } - }; - const getSystemCronStatus = () => { - const config = readSystemCronConfig(); - return { - enabled: config.enabled, - schedule: config.schedule, - installed: fs.existsSync(kSystemCronPath), - scriptExists: fs.existsSync(kSystemCronScriptPath), - }; - }; - const applySystemCronConfig = (nextConfig) => { - fs.mkdirSync(`${OPENCLAW_DIR}/cron`, { recursive: true }); - fs.writeFileSync( - kSystemCronConfigPath, - JSON.stringify(nextConfig, null, 2), - ); - if (nextConfig.enabled) { - fs.writeFileSync( - kSystemCronPath, - buildSystemCronContent(nextConfig.schedule), - { - mode: 0o644, - }, - ); - } else { - fs.rmSync(kSystemCronPath, { force: true }); - } - return getSystemCronStatus(); - }; + const readSystemCronConfig = () => + getSystemCronStatus({ + fs, + openclawDir: OPENCLAW_DIR, + platform, + execFileSyncImpl, + }); + const applyManagedCronConfig = (nextConfig) => + applySystemCronConfig({ + fs, + openclawDir: OPENCLAW_DIR, + nextConfig, + platform, + execFileSyncImpl, + }); const isVisibleInEnvars = (def) => def?.visibleInEnvars !== false; app.get("/api/env", (req, res) => { @@ -331,12 +297,12 @@ const registerSystemRoutes = ({ channels: getChannelStatus(), repo, openclawVersion, - syncCron: getSystemCronStatus(), + syncCron: readSystemCronConfig(), }); }); app.get("/api/sync-cron", (req, res) => { - res.json({ ok: true, ...getSystemCronStatus() }); + res.json({ ok: true, ...readSystemCronConfig() }); }); app.put("/api/sync-cron", (req, res) => { @@ -359,7 +325,7 @@ const registerSystemRoutes = ({ ? schedule.trim() : current.schedule, }; - const status = applySystemCronConfig(nextConfig); + const status = applyManagedCronConfig(nextConfig); res.json({ ok: true, syncCron: status }); }); diff --git a/lib/server/system-cron.js b/lib/server/system-cron.js new file mode 100644 index 0000000..02c43a7 --- /dev/null +++ b/lib/server/system-cron.js @@ -0,0 +1,333 @@ +"use strict"; + +const os = require("os"); +const path = require("path"); +const { spawn } = require("child_process"); +const { buildManagedPaths } = require("./internal-files-migration"); + +const kSystemCronPath = "/etc/cron.d/openclaw-hourly-sync"; +const kSystemCronConfigDir = "cron"; +const kSystemCronConfigFile = "system-sync.json"; +const kDefaultSystemCronSchedule = "0 * * * *"; + +const kSchedulerState = { + active: false, + lastRunKey: "", + timer: null, +}; + +const normalizeCronPlatform = (platform = os.platform()) => + platform === "darwin" ? "darwin" : "linux"; + +const isValidCronSchedule = (value) => + typeof value === "string" && /^(\S+\s+){4}\S+$/.test(value.trim()); + +const getSystemCronPaths = ({ + openclawDir, + platform = os.platform(), + pathModule = path, +}) => { + const normalizedPlatform = normalizeCronPlatform(platform); + const managedPaths = buildManagedPaths({ openclawDir, pathModule }); + const configDir = pathModule.join(openclawDir, kSystemCronConfigDir); + return { + platform: normalizedPlatform, + configDir, + configPath: pathModule.join(configDir, kSystemCronConfigFile), + scriptPath: managedPaths.hourlyGitSyncPath, + logPath: + normalizedPlatform === "darwin" + ? pathModule.join(managedPaths.internalDir, "hourly-git-sync.log") + : "/var/log/openclaw-hourly-sync.log", + installPath: + normalizedPlatform === "darwin" ? "managed scheduler" : kSystemCronPath, + }; +}; + +const buildManagedCronContent = ({ + schedule, + scriptPath, + logPath, + platform = os.platform(), +}) => { + const normalizedPlatform = normalizeCronPlatform(platform); + if (normalizedPlatform === "darwin") { + return [ + "SHELL=/bin/bash", + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + `${schedule} bash "${scriptPath}" >> "${logPath}" 2>&1`, + "", + ].join("\n"); + } + return [ + "SHELL=/bin/bash", + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + `${schedule} root bash "${scriptPath}" >> ${logPath} 2>&1`, + "", + ].join("\n"); +}; + +const readSystemCronConfig = ({ + fs, + openclawDir, + platform = os.platform(), +}) => { + const { configPath } = getSystemCronPaths({ openclawDir, platform }); + try { + const raw = fs.readFileSync(configPath, "utf8"); + const parsed = JSON.parse(raw); + return { + enabled: parsed.enabled !== false, + schedule: isValidCronSchedule(parsed.schedule) + ? parsed.schedule.trim() + : kDefaultSystemCronSchedule, + }; + } catch { + return { + enabled: true, + schedule: kDefaultSystemCronSchedule, + }; + } +}; + +const normalizeCronValue = (value, fieldName) => { + if (fieldName === "dayOfWeek" && value === 7) return 0; + return value; +}; + +const matchCronToken = ({ token, value, min, max, fieldName }) => { + const normalizedToken = String(token || "").trim(); + if (!normalizedToken) return false; + if (normalizedToken === "*") return true; + + const [base, stepRaw] = normalizedToken.split("/"); + const step = stepRaw ? Number.parseInt(stepRaw, 10) : null; + if (stepRaw && (!Number.isFinite(step) || step <= 0)) return false; + + let rangeStart = min; + let rangeEnd = max; + if (base && base !== "*") { + if (base.includes("-")) { + const [startRaw, endRaw] = base.split("-", 2); + rangeStart = normalizeCronValue(Number.parseInt(startRaw, 10), fieldName); + rangeEnd = normalizeCronValue(Number.parseInt(endRaw, 10), fieldName); + } else { + rangeStart = normalizeCronValue(Number.parseInt(base, 10), fieldName); + rangeEnd = rangeStart; + } + } + if ( + !Number.isFinite(rangeStart) || + !Number.isFinite(rangeEnd) || + rangeStart < min || + rangeEnd > max || + rangeStart > rangeEnd + ) { + return false; + } + if (value < rangeStart || value > rangeEnd) return false; + if (!step) return true; + return (value - rangeStart) % step === 0; +}; + +const matchCronField = ({ expression, value, min, max, fieldName }) => + String(expression || "") + .split(",") + .some((token) => matchCronToken({ token, value, min, max, fieldName })); + +const cronMatchesDate = (schedule, date) => { + if (!isValidCronSchedule(schedule)) return false; + const [minuteExpr, hourExpr, dayExpr, monthExpr, weekExpr] = + schedule.trim().split(/\s+/); + const minute = date.getMinutes(); + const hour = date.getHours(); + const dayOfMonth = date.getDate(); + const month = date.getMonth() + 1; + const dayOfWeek = date.getDay(); + + if ( + !matchCronField({ + expression: minuteExpr, + value: minute, + min: 0, + max: 59, + fieldName: "minute", + }) || + !matchCronField({ + expression: hourExpr, + value: hour, + min: 0, + max: 23, + fieldName: "hour", + }) || + !matchCronField({ + expression: monthExpr, + value: month, + min: 1, + max: 12, + fieldName: "month", + }) + ) { + return false; + } + + const dayMatches = matchCronField({ + expression: dayExpr, + value: dayOfMonth, + min: 1, + max: 31, + fieldName: "dayOfMonth", + }); + const weekMatches = matchCronField({ + expression: weekExpr, + value: dayOfWeek, + min: 0, + max: 7, + fieldName: "dayOfWeek", + }); + + const dayRestricted = dayExpr !== "*"; + const weekRestricted = weekExpr !== "*"; + if (dayRestricted && weekRestricted) return dayMatches || weekMatches; + return dayMatches && weekMatches; +}; + +const runManagedSchedulerTick = ({ fs, openclawDir, logger = console }) => { + const config = readSystemCronConfig({ fs, openclawDir, platform: "darwin" }); + if (!config.enabled) return; + const now = new Date(); + if (!cronMatchesDate(config.schedule, now)) return; + const runKey = [ + now.getFullYear(), + now.getMonth(), + now.getDate(), + now.getHours(), + now.getMinutes(), + ].join(":"); + if (kSchedulerState.lastRunKey === runKey) return; + kSchedulerState.lastRunKey = runKey; + + const { scriptPath, logPath } = getSystemCronPaths({ + openclawDir, + platform: "darwin", + }); + const child = spawn("bash", [scriptPath], { + detached: true, + stdio: ["ignore", "ignore", "ignore"], + env: { + ...process.env, + ALPHACLAW_SYNC_LOG_PATH: logPath, + }, + }); + child.unref(); + logger.log?.(`[alphaclaw] Managed scheduler triggered (${config.schedule})`); +}; + +const startManagedScheduler = ({ + fs, + openclawDir, + platform = os.platform(), + logger = console, +}) => { + if (normalizeCronPlatform(platform) !== "darwin") return false; + if (kSchedulerState.timer) return true; + kSchedulerState.active = true; + + const scheduleNextTick = () => { + const now = new Date(); + const delayMs = + (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 50; + kSchedulerState.timer = setTimeout(() => { + runManagedSchedulerTick({ fs, openclawDir, logger }); + scheduleNextTick(); + }, Math.max(delayMs, 250)); + if (typeof kSchedulerState.timer.unref === "function") { + kSchedulerState.timer.unref(); + } + }; + + scheduleNextTick(); + return true; +}; + +const stopManagedScheduler = () => { + kSchedulerState.active = false; + kSchedulerState.lastRunKey = ""; + if (kSchedulerState.timer) { + clearTimeout(kSchedulerState.timer); + kSchedulerState.timer = null; + } +}; + +const getSystemCronStatus = ({ + fs, + openclawDir, + platform = os.platform(), +}) => { + const paths = getSystemCronPaths({ openclawDir, platform }); + const config = readSystemCronConfig({ fs, openclawDir, platform }); + return { + enabled: config.enabled, + schedule: config.schedule, + installed: + paths.platform === "darwin" + ? kSchedulerState.active + : fs.existsSync(kSystemCronPath), + scriptExists: fs.existsSync(paths.scriptPath), + platform: paths.platform, + installMethod: + paths.platform === "darwin" ? "managed_scheduler" : "system_cron", + }; +}; + +const applySystemCronConfig = ({ + fs, + openclawDir, + nextConfig, + platform = os.platform(), +}) => { + const paths = getSystemCronPaths({ openclawDir, platform }); + const normalizedConfig = { + enabled: nextConfig.enabled !== false, + schedule: isValidCronSchedule(nextConfig.schedule) + ? nextConfig.schedule.trim() + : kDefaultSystemCronSchedule, + }; + fs.mkdirSync(paths.configDir, { recursive: true }); + fs.writeFileSync(paths.configPath, JSON.stringify(normalizedConfig, null, 2)); + if (paths.platform === "darwin") { + if (!normalizedConfig.enabled) kSchedulerState.lastRunKey = ""; + } else if (normalizedConfig.enabled) { + fs.writeFileSync( + kSystemCronPath, + buildManagedCronContent({ + schedule: normalizedConfig.schedule, + scriptPath: paths.scriptPath, + logPath: paths.logPath, + platform: paths.platform, + }), + { mode: 0o644 }, + ); + } else { + fs.rmSync(kSystemCronPath, { force: true }); + } + return getSystemCronStatus({ + fs, + openclawDir, + platform: paths.platform, + }); +}; + +module.exports = { + applySystemCronConfig, + buildManagedCronContent, + getSystemCronPaths, + getSystemCronStatus, + isValidCronSchedule, + kDefaultSystemCronSchedule, + kSystemCronPath, + normalizeCronPlatform, + readSystemCronConfig, + startManagedScheduler, + stopManagedScheduler, +}; diff --git a/tests/server/routes-onboarding.test.js b/tests/server/routes-onboarding.test.js index 1fae056..ab30fc1 100644 --- a/tests/server/routes-onboarding.test.js +++ b/tests/server/routes-onboarding.test.js @@ -62,6 +62,8 @@ const createBaseDeps = ({ onboarded = false, hasCodexOauth = false } = {}) => { ensureGatewayProxyConfig: vi.fn(), getBaseUrl: vi.fn(() => "https://example.com"), startGateway: vi.fn(), + platform: "linux", + execFileSyncImpl: vi.fn(() => ""), }; }; @@ -362,6 +364,29 @@ describe("server/routes/onboarding", () => { }); }); + it("installs deterministic hourly git sync config for the managed scheduler on macOS", async () => { + const deps = createBaseDeps(); + deps.platform = "darwin"; + deps.fs.readFileSync.mockImplementation((p) => { + if (p === "/tmp/openclaw/openclaw.json") return "{}"; + if (p === path.join(kSetupDir, "skills", "control-ui", "SKILL.md")) return "BASE={{BASE_URL}}"; + if (p === path.join(kSetupDir, "core-prompts", "TOOLS.md")) return "Setup: {{SETUP_UI_URL}}"; + if (p === path.join(kSetupDir, "hourly-git-sync.sh")) return "echo Auto-commit hourly sync"; + return "{}"; + }); + const app = createApp(deps); + mockGithubVerifyAndCreate(); + + const res = await request(app).post("/api/onboard").send(makeValidBody()); + + expect(res.status).toBe(200); + expect(deps.fs.writeFileSync).toHaveBeenCalledWith( + "/tmp/openclaw/cron/system-sync.json", + expect.stringContaining('"schedule": "0 * * * *"'), + ); + expect(deps.execFileSyncImpl).not.toHaveBeenCalled(); + }); + it("rejects onboarding when workspace repo already exists", async () => { const deps = createBaseDeps(); deps.fs.readFileSync.mockImplementation((p) => { diff --git a/tests/server/routes-system.test.js b/tests/server/routes-system.test.js index fd3a05c..6936cbe 100644 --- a/tests/server/routes-system.test.js +++ b/tests/server/routes-system.test.js @@ -84,6 +84,8 @@ const createSystemDeps = () => { removeApiKeyProfileForEnvVar: vi.fn(), }, OPENCLAW_DIR: "/tmp/openclaw", + platform: "linux", + execFileSyncImpl: vi.fn(() => ""), }; return deps; }; @@ -367,6 +369,36 @@ describe("server/routes/system", () => { expect(res.body.ok).toBe(true); }); + it("updates sync cron config for the managed scheduler on macOS", async () => { + const deps = createSystemDeps(); + deps.platform = "darwin"; + deps.fs.readFileSync + .mockReturnValueOnce(JSON.stringify({ enabled: true, schedule: "0 * * * *" })) + .mockReturnValueOnce(JSON.stringify({ enabled: true, schedule: "*/20 * * * *" })); + const app = createApp(deps); + + const res = await request(app).put("/api/sync-cron").send({ + enabled: true, + schedule: "*/20 * * * *", + }); + + expect(res.status).toBe(200); + expect(deps.fs.writeFileSync).toHaveBeenCalledWith( + "/tmp/openclaw/cron/system-sync.json", + expect.stringContaining('"schedule": "*/20 * * * *"'), + ); + expect(deps.execFileSyncImpl).not.toHaveBeenCalled(); + expect(res.body.syncCron).toEqual( + expect.objectContaining({ + enabled: true, + schedule: "*/20 * * * *", + installed: false, + platform: "darwin", + installMethod: "managed_scheduler", + }), + ); + }); + it("returns alphaclaw version status on GET /api/alphaclaw/version", async () => { const deps = createSystemDeps(); const app = createApp(deps);