@chrysb/alphaclaw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/bin/alphaclaw.js +338 -0
  2. package/lib/public/icons/chevron-down.svg +9 -0
  3. package/lib/public/js/app.js +325 -0
  4. package/lib/public/js/components/badge.js +16 -0
  5. package/lib/public/js/components/channels.js +36 -0
  6. package/lib/public/js/components/credentials-modal.js +336 -0
  7. package/lib/public/js/components/device-pairings.js +72 -0
  8. package/lib/public/js/components/envars.js +354 -0
  9. package/lib/public/js/components/gateway.js +163 -0
  10. package/lib/public/js/components/google.js +223 -0
  11. package/lib/public/js/components/icons.js +23 -0
  12. package/lib/public/js/components/models.js +461 -0
  13. package/lib/public/js/components/pairings.js +74 -0
  14. package/lib/public/js/components/scope-picker.js +106 -0
  15. package/lib/public/js/components/toast.js +31 -0
  16. package/lib/public/js/components/welcome.js +541 -0
  17. package/lib/public/js/hooks/usePolling.js +29 -0
  18. package/lib/public/js/lib/api.js +196 -0
  19. package/lib/public/js/lib/model-config.js +88 -0
  20. package/lib/public/login.html +90 -0
  21. package/lib/public/setup.html +33 -0
  22. package/lib/scripts/systemctl +56 -0
  23. package/lib/server/auth-profiles.js +101 -0
  24. package/lib/server/commands.js +84 -0
  25. package/lib/server/constants.js +282 -0
  26. package/lib/server/env.js +78 -0
  27. package/lib/server/gateway.js +262 -0
  28. package/lib/server/helpers.js +192 -0
  29. package/lib/server/login-throttle.js +86 -0
  30. package/lib/server/onboarding/cron.js +51 -0
  31. package/lib/server/onboarding/github.js +49 -0
  32. package/lib/server/onboarding/index.js +127 -0
  33. package/lib/server/onboarding/openclaw.js +171 -0
  34. package/lib/server/onboarding/validation.js +107 -0
  35. package/lib/server/onboarding/workspace.js +52 -0
  36. package/lib/server/openclaw-version.js +179 -0
  37. package/lib/server/routes/auth.js +80 -0
  38. package/lib/server/routes/codex.js +204 -0
  39. package/lib/server/routes/google.js +390 -0
  40. package/lib/server/routes/models.js +68 -0
  41. package/lib/server/routes/onboarding.js +116 -0
  42. package/lib/server/routes/pages.js +21 -0
  43. package/lib/server/routes/pairings.js +134 -0
  44. package/lib/server/routes/proxy.js +29 -0
  45. package/lib/server/routes/system.js +213 -0
  46. package/lib/server.js +161 -0
  47. package/lib/setup/core-prompts/AGENTS.md +22 -0
  48. package/lib/setup/core-prompts/TOOLS.md +18 -0
  49. package/lib/setup/env.template +19 -0
  50. package/lib/setup/gitignore +12 -0
  51. package/lib/setup/hourly-git-sync.sh +86 -0
  52. package/lib/setup/skills/control-ui/SKILL.md +70 -0
  53. package/package.json +34 -0
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("fs");
5
+ const os = require("os");
6
+ const path = require("path");
7
+ const { execSync } = require("child_process");
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Parse CLI flags
11
+ // ---------------------------------------------------------------------------
12
+
13
+ const args = process.argv.slice(2);
14
+ const command = args.find((a) => !a.startsWith("-"));
15
+
16
+ if (!command || command === "help" || args.includes("--help")) {
17
+ console.log(`
18
+ Usage: alphaclaw <command> [options]
19
+
20
+ Commands:
21
+ start Start the AlphaClaw server (Setup UI + gateway manager)
22
+
23
+ Options:
24
+ --root-dir <path> Persistent data directory (default: ~/.alphaclaw)
25
+ --port <number> Server port (default: 3000)
26
+ --help Show this help message
27
+ `);
28
+ process.exit(0);
29
+ }
30
+
31
+ const flagValue = (flag) => {
32
+ const idx = args.indexOf(flag);
33
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
34
+ };
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // 1. Resolve root directory (before requiring any lib/ modules)
38
+ // ---------------------------------------------------------------------------
39
+
40
+ const rootDir = flagValue("--root-dir")
41
+ || process.env.ALPHACLAW_ROOT_DIR
42
+ || path.join(os.homedir(), ".alphaclaw");
43
+
44
+ process.env.ALPHACLAW_ROOT_DIR = rootDir;
45
+
46
+ const portFlag = flagValue("--port");
47
+ if (portFlag) {
48
+ process.env.PORT = portFlag;
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // 2. Create directory structure
53
+ // ---------------------------------------------------------------------------
54
+
55
+ const openclawDir = path.join(rootDir, ".openclaw");
56
+ fs.mkdirSync(openclawDir, { recursive: true });
57
+ console.log(`[alphaclaw] Root directory: ${rootDir}`);
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // 3. Symlink ~/.openclaw -> <root>/.openclaw
61
+ // ---------------------------------------------------------------------------
62
+
63
+ const homeOpenclawLink = path.join(os.homedir(), ".openclaw");
64
+ try {
65
+ if (!fs.existsSync(homeOpenclawLink)) {
66
+ fs.symlinkSync(openclawDir, homeOpenclawLink);
67
+ console.log(`[alphaclaw] Symlinked ${homeOpenclawLink} -> ${openclawDir}`);
68
+ }
69
+ } catch (e) {
70
+ console.log(`[alphaclaw] Symlink skipped: ${e.message}`);
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // 4. Seed .env from template if missing; load into process.env
75
+ // ---------------------------------------------------------------------------
76
+
77
+ const envFilePath = path.join(rootDir, ".env");
78
+ const setupDir = path.join(__dirname, "..", "lib", "setup");
79
+
80
+ if (!fs.existsSync(envFilePath)) {
81
+ const templatePath = path.join(setupDir, "env.template");
82
+ if (fs.existsSync(templatePath)) {
83
+ fs.copyFileSync(templatePath, envFilePath);
84
+ console.log("[alphaclaw] Created .env from template");
85
+ }
86
+ }
87
+
88
+ if (fs.existsSync(envFilePath)) {
89
+ const content = fs.readFileSync(envFilePath, "utf8");
90
+ for (const line of content.split("\n")) {
91
+ const trimmed = line.trim();
92
+ if (!trimmed || trimmed.startsWith("#")) continue;
93
+ const eqIdx = trimmed.indexOf("=");
94
+ if (eqIdx === -1) continue;
95
+ const key = trimmed.slice(0, eqIdx);
96
+ const value = trimmed.slice(eqIdx + 1);
97
+ if (value) process.env[key] = value;
98
+ }
99
+ console.log("[alphaclaw] Loaded .env");
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // 5. Set OPENCLAW_HOME globally so all child processes inherit it
104
+ // ---------------------------------------------------------------------------
105
+
106
+ process.env.OPENCLAW_HOME = rootDir;
107
+ process.env.OPENCLAW_CONFIG_PATH = path.join(openclawDir, "openclaw.json");
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // 6. Install gog (Google Workspace CLI) if not present
111
+ // ---------------------------------------------------------------------------
112
+
113
+ process.env.XDG_CONFIG_HOME = openclawDir;
114
+
115
+ const gogInstalled = (() => {
116
+ try {
117
+ execSync("command -v gog", { stdio: "ignore" });
118
+ return true;
119
+ } catch {
120
+ return false;
121
+ }
122
+ })();
123
+
124
+ if (!gogInstalled) {
125
+ console.log("[alphaclaw] Installing gog CLI...");
126
+ try {
127
+ const gogVersion = process.env.GOG_VERSION || "0.11.0";
128
+ const platform = os.platform() === "darwin" ? "darwin" : "linux";
129
+ const arch = os.arch() === "arm64" ? "arm64" : "amd64";
130
+ const tarball = `gogcli_${gogVersion}_${platform}_${arch}.tar.gz`;
131
+ const url = `https://github.com/steipete/gogcli/releases/download/v${gogVersion}/${tarball}`;
132
+ 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" });
133
+ console.log("[alphaclaw] gog CLI installed");
134
+ } catch (e) {
135
+ console.log(`[alphaclaw] gog install skipped: ${e.message}`);
136
+ }
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // 7. Configure gog keyring (file backend for headless environments)
141
+ // ---------------------------------------------------------------------------
142
+
143
+ process.env.GOG_KEYRING_PASSWORD = process.env.GOG_KEYRING_PASSWORD || "alphaclaw";
144
+ const gogConfigFile = path.join(openclawDir, "gogcli", "config.json");
145
+
146
+ if (!fs.existsSync(gogConfigFile)) {
147
+ fs.mkdirSync(path.join(openclawDir, "gogcli"), { recursive: true });
148
+ try {
149
+ execSync("gog auth keyring file", { stdio: "ignore" });
150
+ console.log("[alphaclaw] gog keyring configured (file backend)");
151
+ } catch {}
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // 8. Install/reconcile system cron entry
156
+ // ---------------------------------------------------------------------------
157
+
158
+ const hourlyGitSyncPath = path.join(openclawDir, "hourly-git-sync.sh");
159
+
160
+ if (fs.existsSync(hourlyGitSyncPath)) {
161
+ try {
162
+ const syncCronConfig = path.join(openclawDir, "cron", "system-sync.json");
163
+ let cronEnabled = true;
164
+ let cronSchedule = "0 * * * *";
165
+
166
+ if (fs.existsSync(syncCronConfig)) {
167
+ try {
168
+ const cfg = JSON.parse(fs.readFileSync(syncCronConfig, "utf8"));
169
+ cronEnabled = cfg.enabled !== false;
170
+ const schedule = String(cfg.schedule || "").trim();
171
+ if (/^(\S+\s+){4}\S+$/.test(schedule)) cronSchedule = schedule;
172
+ } catch {}
173
+ }
174
+
175
+ const cronFilePath = "/etc/cron.d/openclaw-hourly-sync";
176
+ if (cronEnabled) {
177
+ const cronContent = [
178
+ "SHELL=/bin/bash",
179
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
180
+ `${cronSchedule} root bash "${hourlyGitSyncPath}" >> /var/log/openclaw-hourly-sync.log 2>&1`,
181
+ "",
182
+ ].join("\n");
183
+ fs.writeFileSync(cronFilePath, cronContent, { mode: 0o644 });
184
+ console.log("[alphaclaw] System cron entry installed");
185
+ } else {
186
+ try { fs.unlinkSync(cronFilePath); } catch {}
187
+ console.log("[alphaclaw] System cron entry disabled");
188
+ }
189
+ } catch (e) {
190
+ console.log(`[alphaclaw] Cron setup skipped: ${e.message}`);
191
+ }
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // 9. Start cron daemon if available
196
+ // ---------------------------------------------------------------------------
197
+
198
+ try {
199
+ execSync("command -v cron", { stdio: "ignore" });
200
+ try {
201
+ execSync("pgrep -x cron", { stdio: "ignore" });
202
+ } catch {
203
+ execSync("cron", { stdio: "ignore" });
204
+ }
205
+ console.log("[alphaclaw] Cron daemon running");
206
+ } catch {}
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // 10. Configure gog credentials (if env vars present)
210
+ // ---------------------------------------------------------------------------
211
+
212
+ if (process.env.GOG_CLIENT_CREDENTIALS_JSON && process.env.GOG_REFRESH_TOKEN) {
213
+ try {
214
+ const tmpCreds = `/tmp/gog-creds-${process.pid}.json`;
215
+ const tmpToken = `/tmp/gog-token-${process.pid}.json`;
216
+ fs.writeFileSync(tmpCreds, process.env.GOG_CLIENT_CREDENTIALS_JSON);
217
+ execSync(`gog auth credentials set "${tmpCreds}"`, { stdio: "ignore" });
218
+ fs.unlinkSync(tmpCreds);
219
+ fs.writeFileSync(tmpToken, JSON.stringify({
220
+ email: process.env.GOG_ACCOUNT || "",
221
+ refresh_token: process.env.GOG_REFRESH_TOKEN,
222
+ }));
223
+ execSync(`gog auth tokens import "${tmpToken}"`, { stdio: "ignore" });
224
+ fs.unlinkSync(tmpToken);
225
+ console.log(`[alphaclaw] gog CLI configured for ${process.env.GOG_ACCOUNT || "account"}`);
226
+ } catch (e) {
227
+ console.log(`[alphaclaw] gog credentials setup skipped: ${e.message}`);
228
+ }
229
+ } else {
230
+ console.log("[alphaclaw] Google credentials not set -- skipping gog setup");
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // 11. Reconcile channels if already onboarded
235
+ // ---------------------------------------------------------------------------
236
+
237
+ const configPath = path.join(openclawDir, "openclaw.json");
238
+
239
+ if (fs.existsSync(configPath)) {
240
+ console.log("[alphaclaw] Config exists, reconciling channels...");
241
+
242
+ const githubToken = process.env.GITHUB_TOKEN;
243
+ const githubRepo = process.env.GITHUB_WORKSPACE_REPO;
244
+ if (githubToken && githubRepo && fs.existsSync(path.join(openclawDir, ".git"))) {
245
+ const repoUrl = githubRepo
246
+ .replace(/^git@github\.com:/, "")
247
+ .replace(/^https:\/\/github\.com\//, "")
248
+ .replace(/\.git$/, "");
249
+ const remoteUrl = `https://${githubToken}@github.com/${repoUrl}.git`;
250
+ try {
251
+ execSync(`git remote set-url origin "${remoteUrl}"`, { cwd: openclawDir, stdio: "ignore" });
252
+ console.log("[alphaclaw] Repo ready");
253
+ } catch {}
254
+ }
255
+
256
+ try {
257
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
258
+ if (!cfg.channels) cfg.channels = {};
259
+ if (!cfg.plugins) cfg.plugins = {};
260
+ if (!cfg.plugins.entries) cfg.plugins.entries = {};
261
+ let changed = false;
262
+
263
+ if (process.env.TELEGRAM_BOT_TOKEN && !cfg.channels.telegram) {
264
+ cfg.channels.telegram = {
265
+ enabled: true,
266
+ botToken: process.env.TELEGRAM_BOT_TOKEN,
267
+ dmPolicy: "pairing",
268
+ groupPolicy: "allowlist",
269
+ };
270
+ cfg.plugins.entries.telegram = { enabled: true };
271
+ console.log("[alphaclaw] Telegram added");
272
+ changed = true;
273
+ }
274
+
275
+ if (process.env.DISCORD_BOT_TOKEN && !cfg.channels.discord) {
276
+ cfg.channels.discord = {
277
+ enabled: true,
278
+ token: process.env.DISCORD_BOT_TOKEN,
279
+ dmPolicy: "pairing",
280
+ groupPolicy: "allowlist",
281
+ };
282
+ cfg.plugins.entries.discord = { enabled: true };
283
+ console.log("[alphaclaw] Discord added");
284
+ changed = true;
285
+ }
286
+
287
+ if (changed) {
288
+ let content = JSON.stringify(cfg, null, 2);
289
+ const replacements = [
290
+ [process.env.OPENCLAW_GATEWAY_TOKEN, "${OPENCLAW_GATEWAY_TOKEN}"],
291
+ [process.env.ANTHROPIC_API_KEY, "${ANTHROPIC_API_KEY}"],
292
+ [process.env.ANTHROPIC_TOKEN, "${ANTHROPIC_TOKEN}"],
293
+ [process.env.TELEGRAM_BOT_TOKEN, "${TELEGRAM_BOT_TOKEN}"],
294
+ [process.env.DISCORD_BOT_TOKEN, "${DISCORD_BOT_TOKEN}"],
295
+ [process.env.OPENAI_API_KEY, "${OPENAI_API_KEY}"],
296
+ [process.env.GEMINI_API_KEY, "${GEMINI_API_KEY}"],
297
+ [process.env.NOTION_API_KEY, "${NOTION_API_KEY}"],
298
+ [process.env.BRAVE_API_KEY, "${BRAVE_API_KEY}"],
299
+ ];
300
+ for (const [secret, envRef] of replacements) {
301
+ if (secret && secret.length > 8) {
302
+ content = content.split(secret).join(envRef);
303
+ }
304
+ }
305
+ fs.writeFileSync(configPath, content);
306
+ console.log("[alphaclaw] Config updated and sanitized");
307
+ }
308
+ } catch (e) {
309
+ console.error(`[alphaclaw] Channel reconciliation error: ${e.message}`);
310
+ }
311
+ } else {
312
+ console.log("[alphaclaw] No config yet -- onboarding will run from the Setup UI");
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // 12. Install systemctl shim if in Docker (no real systemd)
317
+ // ---------------------------------------------------------------------------
318
+
319
+ try {
320
+ execSync("command -v systemctl", { stdio: "ignore" });
321
+ } catch {
322
+ const shimSrc = path.join(__dirname, "..", "lib", "scripts", "systemctl");
323
+ const shimDest = "/usr/local/bin/systemctl";
324
+ try {
325
+ fs.copyFileSync(shimSrc, shimDest);
326
+ fs.chmodSync(shimDest, 0o755);
327
+ console.log("[alphaclaw] systemctl shim installed");
328
+ } catch (e) {
329
+ console.log(`[alphaclaw] systemctl shim skipped: ${e.message}`);
330
+ }
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // 13. Start Express server
335
+ // ---------------------------------------------------------------------------
336
+
337
+ console.log("[alphaclaw] Setup complete -- starting server");
338
+ require("../lib/server.js");
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 20 20" fill="none">
2
+ <path
3
+ d="M5 7.5L10 12.5L15 7.5"
4
+ stroke="currentColor"
5
+ stroke-width="1.8"
6
+ stroke-linecap="round"
7
+ stroke-linejoin="round"
8
+ />
9
+ </svg>
@@ -0,0 +1,325 @@
1
+ import { h, render } from "https://esm.sh/preact";
2
+ import { useState, useEffect } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import {
5
+ fetchStatus,
6
+ fetchPairings,
7
+ approvePairing,
8
+ rejectPairing,
9
+ fetchDevicePairings,
10
+ approveDevice,
11
+ rejectDevice,
12
+ fetchOnboardStatus,
13
+ fetchDashboardUrl,
14
+ updateSyncCron,
15
+ } from "./lib/api.js";
16
+ import { usePolling } from "./hooks/usePolling.js";
17
+ import { Gateway } from "./components/gateway.js";
18
+ import { Channels, ALL_CHANNELS } from "./components/channels.js";
19
+ import { Pairings } from "./components/pairings.js";
20
+ import { DevicePairings } from "./components/device-pairings.js";
21
+ import { Google } from "./components/google.js";
22
+ import { Models } from "./components/models.js";
23
+ import { Welcome } from "./components/welcome.js";
24
+ import { Envars } from "./components/envars.js";
25
+ import { ToastContainer, showToast } from "./components/toast.js";
26
+ import { ChevronDownIcon } from "./components/icons.js";
27
+ const html = htm.bind(h);
28
+
29
+ const GeneralTab = ({ onSwitchTab }) => {
30
+ const [googleKey, setGoogleKey] = useState(0);
31
+ const [dashboardLoading, setDashboardLoading] = useState(false);
32
+
33
+ const statusPoll = usePolling(fetchStatus, 15000);
34
+ const status = statusPoll.data;
35
+ const gatewayStatus = status?.gateway ?? null;
36
+ const channels = status?.channels ?? null;
37
+ const repo = status?.repo || null;
38
+ const syncCron = status?.syncCron || null;
39
+ const openclawVersion = status?.openclawVersion || null;
40
+ const [syncCronEnabled, setSyncCronEnabled] = useState(true);
41
+ const [syncCronSchedule, setSyncCronSchedule] = useState("0 * * * *");
42
+ const [savingSyncCron, setSavingSyncCron] = useState(false);
43
+ const [syncCronChoice, setSyncCronChoice] = useState("0 * * * *");
44
+
45
+ const hasUnpaired = ALL_CHANNELS.some((ch) => {
46
+ const info = channels?.[ch];
47
+ return info && info.status !== "paired";
48
+ });
49
+
50
+ const pairingsPoll = usePolling(
51
+ async () => {
52
+ const d = await fetchPairings();
53
+ return d.pending || [];
54
+ },
55
+ 1000,
56
+ { enabled: hasUnpaired && gatewayStatus === "running" },
57
+ );
58
+ const pending = pairingsPoll.data || [];
59
+
60
+ // Poll status faster when gateway isn't running yet
61
+ useEffect(() => {
62
+ if (!gatewayStatus || gatewayStatus !== "running") {
63
+ const id = setInterval(statusPoll.refresh, 3000);
64
+ return () => clearInterval(id);
65
+ }
66
+ }, [gatewayStatus, statusPoll.refresh]);
67
+
68
+ const refreshAfterAction = () => {
69
+ setTimeout(pairingsPoll.refresh, 500);
70
+ setTimeout(pairingsPoll.refresh, 2000);
71
+ setTimeout(statusPoll.refresh, 3000);
72
+ };
73
+
74
+ const handleApprove = async (id, channel) => {
75
+ await approvePairing(id, channel);
76
+ refreshAfterAction();
77
+ };
78
+
79
+ const handleReject = async (id, channel) => {
80
+ await rejectPairing(id, channel);
81
+ refreshAfterAction();
82
+ };
83
+
84
+ const devicePoll = usePolling(
85
+ async () => {
86
+ const d = await fetchDevicePairings();
87
+ return d.pending || [];
88
+ },
89
+ 2000,
90
+ { enabled: gatewayStatus === "running" },
91
+ );
92
+ const devicePending = devicePoll.data || [];
93
+
94
+ const handleDeviceApprove = async (id) => {
95
+ await approveDevice(id);
96
+ setTimeout(devicePoll.refresh, 500);
97
+ setTimeout(devicePoll.refresh, 2000);
98
+ };
99
+
100
+ const handleDeviceReject = async (id) => {
101
+ await rejectDevice(id);
102
+ setTimeout(devicePoll.refresh, 500);
103
+ setTimeout(devicePoll.refresh, 2000);
104
+ };
105
+
106
+ const fullRefresh = () => {
107
+ statusPoll.refresh();
108
+ pairingsPoll.refresh();
109
+ devicePoll.refresh();
110
+ setGoogleKey((k) => k + 1);
111
+ };
112
+
113
+ useEffect(() => {
114
+ if (!syncCron) return;
115
+ setSyncCronEnabled(syncCron.enabled !== false);
116
+ setSyncCronSchedule(syncCron.schedule || "0 * * * *");
117
+ setSyncCronChoice(syncCron.enabled === false ? "disabled" : syncCron.schedule || "0 * * * *");
118
+ }, [syncCron?.enabled, syncCron?.schedule]);
119
+
120
+ const saveSyncCronSettings = async ({ enabled = syncCronEnabled, schedule = syncCronSchedule }) => {
121
+ if (savingSyncCron) return;
122
+ setSavingSyncCron(true);
123
+ try {
124
+ const data = await updateSyncCron({ enabled, schedule });
125
+ if (!data.ok) throw new Error(data.error || "Could not save sync settings");
126
+ showToast("Sync schedule updated", "success");
127
+ statusPoll.refresh();
128
+ } catch (err) {
129
+ showToast(err.message || "Could not save sync settings", "error");
130
+ }
131
+ setSavingSyncCron(false);
132
+ };
133
+
134
+ const syncCronStatusText = syncCronEnabled ? "Enabled" : "Disabled";
135
+
136
+ return html`
137
+ <div class="space-y-4">
138
+ <${Gateway} status=${gatewayStatus} openclawVersion=${openclawVersion} />
139
+ <${Channels} channels=${channels} onSwitchTab=${onSwitchTab} />
140
+ <${Pairings}
141
+ pending=${pending}
142
+ channels=${channels}
143
+ visible=${hasUnpaired}
144
+ onApprove=${handleApprove}
145
+ onReject=${handleReject}
146
+ />
147
+ <${Google} key=${googleKey} gatewayStatus=${gatewayStatus} />
148
+
149
+ ${repo && html`
150
+ <div class="bg-surface border border-border rounded-xl p-4">
151
+ <div class="flex items-center justify-between gap-3">
152
+ <div class="flex items-center gap-2 min-w-0">
153
+ <svg class="w-4 h-4 text-gray-400" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>
154
+ <a href="https://github.com/${repo}" target="_blank" class="text-sm text-gray-400 hover:text-gray-200 transition-colors truncate">${repo}</a>
155
+ </div>
156
+ <div class="flex items-center gap-2 shrink-0">
157
+ <span class="text-xs text-gray-400">Auto-sync</span>
158
+ <div class="relative">
159
+ <select
160
+ value=${syncCronChoice}
161
+ onchange=${(e) => {
162
+ const nextChoice = e.target.value;
163
+ setSyncCronChoice(nextChoice);
164
+ const nextEnabled = nextChoice !== "disabled";
165
+ const nextSchedule = nextEnabled ? nextChoice : syncCronSchedule;
166
+ setSyncCronEnabled(nextEnabled);
167
+ setSyncCronSchedule(nextSchedule);
168
+ saveSyncCronSettings({
169
+ enabled: nextEnabled,
170
+ schedule: nextSchedule,
171
+ });
172
+ }}
173
+ disabled=${savingSyncCron}
174
+ class="appearance-none bg-black/30 border border-border rounded-lg pl-2.5 pr-9 py-1.5 text-xs text-gray-300 ${savingSyncCron
175
+ ? "opacity-50 cursor-not-allowed"
176
+ : ""}"
177
+ title=${syncCron?.installed === false ? "Not Installed Yet" : syncCronStatusText}
178
+ >
179
+ <option value="disabled">Disabled</option>
180
+ <option value="*/30 * * * *">Every 30 min</option>
181
+ <option value="0 * * * *">Hourly</option>
182
+ <option value="0 0 * * *">Daily</option>
183
+ </select>
184
+ <${ChevronDownIcon}
185
+ className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-500"
186
+ />
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ `}
192
+
193
+ <div class="bg-surface border border-border rounded-xl p-4">
194
+ <div class="flex items-center justify-between">
195
+ <div>
196
+ <h2 class="font-semibold text-sm">Gateway Dashboard</h2>
197
+ </div>
198
+ <button
199
+ onclick=${async () => {
200
+ if (dashboardLoading) return;
201
+ setDashboardLoading(true);
202
+ try {
203
+ const data = await fetchDashboardUrl();
204
+ console.log('[dashboard] response:', JSON.stringify(data));
205
+ window.open(data.url || '/openclaw', '_blank');
206
+ } catch (err) {
207
+ console.error('[dashboard] error:', err);
208
+ window.open('/openclaw', '_blank');
209
+ }
210
+ setDashboardLoading(false);
211
+ }}
212
+ disabled=${dashboardLoading}
213
+ class="text-xs px-2.5 py-1 rounded-lg border border-border text-gray-500 hover:text-gray-300 hover:border-gray-500 transition-colors ${dashboardLoading ? 'opacity-50 cursor-not-allowed' : ''}"
214
+ >
215
+ ${dashboardLoading ? 'Opening...' : 'Open'}
216
+ </button>
217
+ </div>
218
+ <${DevicePairings}
219
+ pending=${devicePending}
220
+ onApprove=${handleDeviceApprove}
221
+ onReject=${handleDeviceReject}
222
+ />
223
+ </div>
224
+
225
+ <p class="text-center text-gray-600 text-xs">
226
+ <a
227
+ href="#"
228
+ onclick=${(e) => {
229
+ e.preventDefault();
230
+ fullRefresh();
231
+ }}
232
+ class="text-gray-500 hover:text-gray-300"
233
+ >Refresh all</a
234
+ >
235
+ </p>
236
+ </div>
237
+ `;
238
+ };
239
+
240
+ function App() {
241
+ const [onboarded, setOnboarded] = useState(null);
242
+ const [tab, setTab] = useState("general");
243
+
244
+ useEffect(() => {
245
+ fetchOnboardStatus()
246
+ .then((data) => setOnboarded(data.onboarded))
247
+ .catch(() => setOnboarded(false));
248
+ }, []);
249
+
250
+ // Still loading onboard status
251
+ if (onboarded === null) {
252
+ return html`
253
+ <div class="max-w-lg w-full flex items-center justify-center py-20">
254
+ <svg
255
+ class="animate-spin h-6 w-6 text-gray-500"
256
+ viewBox="0 0 24 24"
257
+ fill="none"
258
+ >
259
+ <circle
260
+ class="opacity-25"
261
+ cx="12"
262
+ cy="12"
263
+ r="10"
264
+ stroke="currentColor"
265
+ stroke-width="4"
266
+ />
267
+ <path
268
+ class="opacity-75"
269
+ fill="currentColor"
270
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
271
+ />
272
+ </svg>
273
+ </div>
274
+ <${ToastContainer} />
275
+ `;
276
+ }
277
+
278
+ if (!onboarded) {
279
+ return html`
280
+ <${Welcome} onComplete=${() => setOnboarded(true)} />
281
+ <${ToastContainer} />
282
+ `;
283
+ }
284
+
285
+ return html`
286
+ <div class="max-w-lg w-full">
287
+ <div class="sticky top-0 z-10 bg-[#0a0a0a] pb-3">
288
+ <div class="flex items-center gap-3 pb-3">
289
+ <div class="text-4xl">🦞</div>
290
+ <div>
291
+ <h1 class="text-2xl font-semibold">OpenClaw Setup</h1>
292
+ <p class="text-gray-500 text-sm">This should be easy, right?</p>
293
+ </div>
294
+ </div>
295
+
296
+ <div class="flex gap-1 border-b border-border">
297
+ ${["general", "models", "envars"].map(
298
+ (t) => html`
299
+ <button
300
+ onclick=${() => setTab(t)}
301
+ class="px-4 py-2 text-sm font-medium border-b-2 transition-colors ${tab ===
302
+ t
303
+ ? "border-white text-white"
304
+ : "border-transparent text-gray-500 hover:text-gray-300"}"
305
+ >
306
+ ${t === "general" ? "General" : t === "models" ? "Models" : "Envars"}
307
+ </button>
308
+ `,
309
+ )}
310
+ </div>
311
+ </div>
312
+
313
+ <div class="space-y-4 pt-4">
314
+ ${tab === "general"
315
+ ? html`<${GeneralTab} onSwitchTab=${setTab} />`
316
+ : tab === "models"
317
+ ? html`<${Models} />`
318
+ : html`<${Envars} />`}
319
+ </div>
320
+ </div>
321
+ <${ToastContainer} />
322
+ `;
323
+ }
324
+
325
+ render(html`<${App} />`, document.getElementById("app"));
@@ -0,0 +1,16 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+
4
+ const html = htm.bind(h);
5
+
6
+ const kToneClasses = {
7
+ success: "bg-green-500/10 text-green-500",
8
+ warning: "bg-yellow-500/10 text-yellow-500",
9
+ neutral: "bg-gray-500/10 text-gray-400",
10
+ };
11
+
12
+ export const Badge = ({ tone = "neutral", children }) => html`
13
+ <span class="text-xs px-2 py-0.5 rounded-full font-medium ${kToneClasses[tone] || kToneClasses.neutral}">
14
+ ${children}
15
+ </span>
16
+ `;