Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<a href="https://render.com/deploy?repo=https://github.com/chrysb/openclaw-render-template"><img src="https://render.com/images/deploy-to-render-button.svg" alt="Deploy to Render" /></a>
</p>

> **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

Expand Down
174 changes: 125 additions & 49 deletions bin/alphaclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -148,10 +223,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).
Expand Down Expand Up @@ -235,6 +316,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(
Expand Down Expand Up @@ -512,11 +595,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}`);
}
Expand Down Expand Up @@ -554,35 +638,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}`);
}
Expand All @@ -592,15 +666,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
Expand Down Expand Up @@ -826,11 +902,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}`);
}
Expand All @@ -842,9 +918,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);
Expand Down Expand Up @@ -875,7 +951,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}`);
Expand Down
11 changes: 10 additions & 1 deletion lib/public/js/components/gateway.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const formatDuration = (ms) => {
const VersionRow = ({
label,
currentVersion,
helperText = "",
fetchVersion,
applyUpdate,
updateInProgress = false,
Expand Down Expand Up @@ -205,6 +206,9 @@ const VersionRow = ({
? `${version}`
: "..."}
</p>
${helperText
? html`<p class="mt-1 text-xs text-gray-500">${helperText}</p>`
: null}
${error &&
effectiveHasUpdate &&
html`<div
Expand Down Expand Up @@ -293,6 +297,7 @@ const VersionRow = ({
export const Gateway = ({
status,
openclawVersion,
diagnostics = null,
restarting = false,
onRestart,
watchdogStatus = null,
Expand Down Expand Up @@ -346,6 +351,9 @@ export const Gateway = ({
}, 1000);
return () => clearInterval(id);
}, []);
const openclawVersionHelperText = diagnostics?.readOnlyMode
? "Showing installed OpenClaw binary version. Read-only mode may be attached to an existing config created by a different version."
: "";

return html` <div class="bg-surface border border-border rounded-xl p-4">
<div class="space-y-2">
Expand Down Expand Up @@ -438,8 +446,9 @@ export const Gateway = ({
</div>
<div class="mt-3">
<${VersionRow}
label="OpenClaw"
label=${diagnostics?.readOnlyMode ? "OpenClaw Binary" : "OpenClaw"}
currentVersion=${openclawVersion}
helperText=${openclawVersionHelperText}
fetchVersion=${fetchOpenclawVersion}
applyUpdate=${onOpenclawUpdate}
updateInProgress=${openclawUpdateInProgress}
Expand Down
1 change: 1 addition & 0 deletions lib/public/js/components/general/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const GeneralTab = ({
<${Gateway}
status=${state.gatewayStatus}
openclawVersion=${state.openclawVersion}
diagnostics=${state.diagnostics}
restarting=${restartingGateway}
onRestart=${onRestartGateway}
watchdogStatus=${state.watchdogStatus}
Expand Down
2 changes: 2 additions & 0 deletions lib/public/js/components/general/use-general-tab.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const useGeneralTab = ({
const repo = status?.repo || null;
const syncCron = status?.syncCron || null;
const openclawVersion = status?.openclawVersion || null;
const diagnostics = status?.diagnostics || null;

const hasUnpaired = ALL_CHANNELS.some((channel) => {
const info = channels?.[channel];
Expand Down Expand Up @@ -267,6 +268,7 @@ export const useGeneralTab = ({
dashboardLoading,
devicePending,
doctorStatus,
diagnostics,
gatewayStatus,
hasUnpaired,
openclawVersion,
Expand Down
6 changes: 6 additions & 0 deletions lib/public/js/components/onboarding/welcome-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const kRepoModeNew = "new";
export const kRepoModeExisting = "existing";
export const kGithubFlowFresh = "fresh";
export const kGithubFlowImport = "import";
export const kGithubFlowReadOnly = "read-only";
export const kGithubTargetRepoModeCreate = "create";
export const kGithubTargetRepoModeExistingEmpty = "existing-empty";

Expand Down Expand Up @@ -66,6 +67,11 @@ export const kWelcomeGroups = [
],
validate: (vals) => {
const githubFlow = vals._GITHUB_FLOW || kGithubFlowFresh;
if (githubFlow === kGithubFlowReadOnly) {
const hasToken = !!String(vals.GITHUB_TOKEN || "").trim();
const hasTarget = !!String(vals.GITHUB_WORKSPACE_REPO || "").trim();
return (hasToken && isValidGithubRepoInput(vals.GITHUB_WORKSPACE_REPO)) || (!hasToken && !hasTarget);
}
const hasTarget = isValidGithubRepoInput(vals.GITHUB_WORKSPACE_REPO);
const hasSource =
githubFlow !== kGithubFlowImport ||
Expand Down
Loading