@chrysb/alphaclaw 0.2.0 → 0.2.2

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.
package/bin/alphaclaw.js CHANGED
@@ -252,13 +252,17 @@ const runGitSync = () => {
252
252
  }
253
253
 
254
254
  const originUrl = `https://github.com/${githubRepo}.git`;
255
- const branch =
256
- String(
257
- execSync("git rev-parse --abbrev-ref HEAD", {
258
- cwd: openclawDir,
259
- encoding: "utf8",
260
- }),
261
- ).trim() || "main";
255
+ let branch = "main";
256
+ try {
257
+ branch =
258
+ String(
259
+ execSync("git symbolic-ref --short HEAD", {
260
+ cwd: openclawDir,
261
+ encoding: "utf8",
262
+ stdio: ["ignore", "pipe", "ignore"],
263
+ }),
264
+ ).trim() || "main";
265
+ } catch {}
262
266
  const askPassPath = path.join(
263
267
  os.tmpdir(),
264
268
  `alphaclaw-git-askpass-${process.pid}.sh`,
@@ -294,6 +298,8 @@ const runGitSync = () => {
294
298
  );
295
299
 
296
300
  runGit(`remote set-url origin ${quoteArg(originUrl)}`);
301
+ runGit(`config user.name ${quoteArg("AlphaClaw Agent")}`);
302
+ runGit(`config user.email ${quoteArg("agent@alphaclaw.md")}`);
297
303
  try {
298
304
  runGit(`ls-remote --exit-code --heads origin ${quoteArg(branch)}`, {
299
305
  withAuth: true,
@@ -316,6 +322,9 @@ const runGitSync = () => {
316
322
  runGit(`push origin ${quoteArg(branch)}`, { withAuth: true });
317
323
  const hash = String(runGit("rev-parse --short HEAD")).trim();
318
324
  console.log(`[alphaclaw] Git sync complete (${hash})`);
325
+ console.log(
326
+ `[alphaclaw] Commit URL: https://github.com/${githubRepo}/commit/${hash}`,
327
+ );
319
328
  return 0;
320
329
  } catch (e) {
321
330
  const details = String(e.stderr || e.stdout || e.message || "").trim();
@@ -175,9 +175,104 @@
175
175
 
176
176
  .statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 16px; margin-left: 2px; }
177
177
 
178
+ .mobile-topbar {
179
+ display: none;
180
+ }
181
+
182
+ .mobile-topbar-menu {
183
+ display: inline-flex;
184
+ align-items: center;
185
+ justify-content: center;
186
+ width: 32px;
187
+ height: 32px;
188
+ border: 1px solid var(--border-strong);
189
+ border-radius: 8px;
190
+ background: var(--bg-hover);
191
+ color: var(--text);
192
+ cursor: pointer;
193
+ }
194
+ .mobile-topbar-menu:hover { background: var(--bg-active); }
195
+
196
+ .mobile-topbar-title {
197
+ display: inline-flex;
198
+ align-items: center;
199
+ justify-content: center;
200
+ width: 100%;
201
+ text-align: center;
202
+ font-size: 14px;
203
+ letter-spacing: 0.03em;
204
+ color: var(--text-muted);
205
+ }
206
+
207
+ .mobile-sidebar-overlay {
208
+ display: none;
209
+ }
210
+
178
211
  /* ── Responsive ────────────────────────────────── */
179
212
 
180
213
  @media (max-width: 768px) {
181
214
  .app-shell { grid-template-columns: 1fr; }
182
- .app-sidebar { display: none; }
215
+ .app-content {
216
+ padding: 0 14px 12px;
217
+ }
218
+
219
+ .mobile-topbar {
220
+ display: flex;
221
+ align-items: center;
222
+ justify-content: center;
223
+ position: sticky;
224
+ top: 0;
225
+ z-index: 15;
226
+ background: var(--panel-bg-contrast);
227
+ border: 0;
228
+ border-bottom: 1px solid var(--panel-border-contrast);
229
+ border-radius: 0;
230
+ min-height: 52px;
231
+ padding: 8px 14px;
232
+ margin: 0 -14px 10px;
233
+ }
234
+
235
+ .mobile-topbar.is-scrolled {
236
+ background: var(--bg-content);
237
+ }
238
+
239
+ .mobile-topbar-menu {
240
+ position: absolute;
241
+ left: 14px;
242
+ top: 50%;
243
+ transform: translateY(-50%);
244
+ }
245
+
246
+ .app-sidebar {
247
+ display: flex;
248
+ position: fixed;
249
+ top: 0;
250
+ left: 0;
251
+ bottom: 24px;
252
+ width: min(260px, 82vw);
253
+ z-index: 30;
254
+ transform: translateX(-100%);
255
+ transition: transform 0.18s ease;
256
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
257
+ }
258
+
259
+ .app-sidebar.mobile-open {
260
+ transform: translateX(0);
261
+ }
262
+
263
+ .mobile-sidebar-overlay {
264
+ display: block;
265
+ position: fixed;
266
+ inset: 0 0 24px 0;
267
+ background: rgba(0, 0, 0, 0.45);
268
+ opacity: 0;
269
+ pointer-events: none;
270
+ transition: opacity 0.18s ease;
271
+ z-index: 20;
272
+ }
273
+
274
+ .mobile-sidebar-overlay.active {
275
+ opacity: 1;
276
+ pointer-events: auto;
277
+ }
183
278
  }
@@ -156,6 +156,32 @@ textarea:focus {
156
156
  background: rgba(99, 235, 255, 0.08);
157
157
  }
158
158
 
159
+ .ac-btn-secondary {
160
+ border: 1px solid var(--panel-border-contrast);
161
+ color: #d1d5db;
162
+ background: rgba(255, 255, 255, 0.03);
163
+ transition:
164
+ border-color 0.15s ease,
165
+ color 0.15s ease,
166
+ background 0.15s ease,
167
+ transform 0.15s ease;
168
+ }
169
+
170
+ .ac-btn-secondary:hover:not(:disabled) {
171
+ border-color: rgba(255, 255, 255, 0.35);
172
+ color: #f3f4f6;
173
+ background: rgba(255, 255, 255, 0.06);
174
+ }
175
+
176
+ .ac-btn-secondary:active:not(:disabled) {
177
+ transform: translateY(1px);
178
+ }
179
+
180
+ .ac-btn-secondary:disabled {
181
+ opacity: 0.5;
182
+ cursor: not-allowed;
183
+ }
184
+
159
185
  .ac-btn-green {
160
186
  border: 1px solid rgba(34, 197, 94, 0.45);
161
187
  background: linear-gradient(
@@ -30,6 +30,7 @@ import { Envars } from "./components/envars.js";
30
30
  import { ToastContainer, showToast } from "./components/toast.js";
31
31
  import { TelegramWorkspace } from "./components/telegram-workspace.js";
32
32
  import { ChevronDownIcon } from "./components/icons.js";
33
+ import { UpdateActionButton } from "./components/update-action-button.js";
33
34
  const html = htm.bind(h);
34
35
  const kUiTabs = ["general", "providers", "envars"];
35
36
  const kSubScreens = ["telegram"];
@@ -236,8 +237,8 @@ const GeneralTab = ({ onSwitchTab, onNavigate, isActive }) => {
236
237
  <div>
237
238
  <h2 class="font-semibold text-sm">OpenClaw Gateway Dashboard</h2>
238
239
  </div>
239
- <button
240
- onclick=${async () => {
240
+ <${UpdateActionButton}
241
+ onClick=${async () => {
241
242
  if (dashboardLoading) return;
242
243
  setDashboardLoading(true);
243
244
  try {
@@ -250,13 +251,11 @@ const GeneralTab = ({ onSwitchTab, onNavigate, isActive }) => {
250
251
  }
251
252
  setDashboardLoading(false);
252
253
  }}
253
- disabled=${dashboardLoading}
254
- 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
255
- ? "opacity-50 cursor-not-allowed"
256
- : ""}"
257
- >
258
- ${dashboardLoading ? "Opening..." : "Open"}
259
- </button>
254
+ loading=${dashboardLoading}
255
+ warning=${false}
256
+ idleLabel="Open"
257
+ loadingLabel="Opening..."
258
+ />
260
259
  </div>
261
260
  <${DevicePairings}
262
261
  pending=${devicePending}
@@ -298,6 +297,8 @@ function App() {
298
297
  const [acDismissed, setAcDismissed] = useState(false);
299
298
  const [authEnabled, setAuthEnabled] = useState(false);
300
299
  const [menuOpen, setMenuOpen] = useState(false);
300
+ const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
301
+ const [mobileTopbarScrolled, setMobileTopbarScrolled] = useState(false);
301
302
  const menuRef = useRef(null);
302
303
 
303
304
  const closeMenu = useCallback((e) => {
@@ -326,6 +327,15 @@ function App() {
326
327
  history.replaceState(null, "", `#${subScreen || tab}`);
327
328
  }, [tab, subScreen]);
328
329
 
330
+ useEffect(() => {
331
+ if (!mobileSidebarOpen) return;
332
+ const previousOverflow = document.body.style.overflow;
333
+ document.body.style.overflow = "hidden";
334
+ return () => {
335
+ document.body.style.overflow = previousOverflow;
336
+ };
337
+ }, [mobileSidebarOpen]);
338
+
329
339
  useEffect(() => {
330
340
  if (!onboarded) return;
331
341
  let active = true;
@@ -363,7 +373,6 @@ function App() {
363
373
  setAcUpdating(false);
364
374
  }
365
375
  };
366
-
367
376
  // Still loading onboard status
368
377
  if (onboarded === null) {
369
378
  return html`
@@ -408,8 +417,20 @@ function App() {
408
417
  `;
409
418
  }
410
419
 
411
- const navigateToSubScreen = (screen) => setSubScreen(screen);
412
- const exitSubScreen = () => setSubScreen(null);
420
+ const navigateToSubScreen = (screen) => {
421
+ setSubScreen(screen);
422
+ setMobileSidebarOpen(false);
423
+ };
424
+ const exitSubScreen = () => {
425
+ setSubScreen(null);
426
+ setMobileSidebarOpen(false);
427
+ };
428
+ const handleAppContentScroll = (e) => {
429
+ const nextScrolled = e.currentTarget.scrollTop > 0;
430
+ setMobileTopbarScrolled((currentScrolled) =>
431
+ currentScrolled === nextScrolled ? currentScrolled : nextScrolled,
432
+ );
433
+ };
413
434
 
414
435
  const kNavItems = [
415
436
  { id: "general", label: "General" },
@@ -419,7 +440,7 @@ function App() {
419
440
 
420
441
  return html`
421
442
  <div class="app-shell">
422
- <div class="app-sidebar">
443
+ <div class=${`app-sidebar ${mobileSidebarOpen ? "mobile-open" : ""}`}>
423
444
  <div class="sidebar-brand">
424
445
  <img src="./img/logo.svg" alt="" width="20" height="20" />
425
446
  <span><span style="color: var(--accent)">alpha</span>claw</span>
@@ -458,7 +479,11 @@ function App() {
458
479
  (item) => html`
459
480
  <a
460
481
  class=${tab === item.id && !subScreen ? "active" : ""}
461
- onclick=${() => { setSubScreen(null); setTab(item.id); }}
482
+ onclick=${() => {
483
+ setSubScreen(null);
484
+ setTab(item.id);
485
+ setMobileSidebarOpen(false);
486
+ }}
462
487
  >
463
488
  ${item.label}
464
489
  </a>
@@ -468,19 +493,42 @@ function App() {
468
493
  <div class="sidebar-footer">
469
494
  ${acHasUpdate && acLatest && !acDismissed
470
495
  ? html`
471
- <button
472
- onclick=${handleAcUpdate}
473
- disabled=${acUpdating}
474
- class="sidebar-update-btn"
475
- >
476
- ${acUpdating ? "Updating..." : `Update to v${acLatest}`}
477
- </button>
496
+ <${UpdateActionButton}
497
+ onClick=${handleAcUpdate}
498
+ loading=${acUpdating}
499
+ warning=${true}
500
+ idleLabel=${`Update to v${acLatest}`}
501
+ loadingLabel="Updating..."
502
+ className="w-full justify-center"
503
+ />
478
504
  `
479
505
  : null}
480
506
  </div>
481
507
  </div>
482
508
 
483
- <div class="app-content">
509
+ <div
510
+ class=${`mobile-sidebar-overlay ${mobileSidebarOpen ? "active" : ""}`}
511
+ onclick=${() => setMobileSidebarOpen(false)}
512
+ />
513
+
514
+ <div class="app-content" onscroll=${handleAppContentScroll}>
515
+ <div class=${`mobile-topbar ${mobileTopbarScrolled ? "is-scrolled" : ""}`}>
516
+ <button
517
+ class="mobile-topbar-menu"
518
+ onclick=${() => setMobileSidebarOpen((open) => !open)}
519
+ aria-label="Open menu"
520
+ aria-expanded=${mobileSidebarOpen ? "true" : "false"}
521
+ >
522
+ <svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor">
523
+ <path
524
+ d="M2 3.75a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75A.75.75 0 012 3.75zm0 4.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75A.75.75 0 012 8zm0 4.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H2.75a.75.75 0 01-.75-.75z"
525
+ />
526
+ </svg>
527
+ </button>
528
+ <span class="mobile-topbar-title">
529
+ <span style="color: var(--accent)">alpha</span>claw
530
+ </span>
531
+ </div>
484
532
  <div class="max-w-2xl w-full mx-auto">
485
533
  ${subScreen === "telegram"
486
534
  ? html`
@@ -17,7 +17,7 @@ export function Channels({ channels, onSwitchTab, onNavigate }) {
17
17
  ${channels ? ALL_CHANNELS.map(ch => {
18
18
  const info = channels[ch];
19
19
  const channelMeta = kChannelMeta[ch] || { label: ch.charAt(0).toUpperCase() + ch.slice(1), iconSrc: '' };
20
- const isClickable = ch === 'telegram' && info && onNavigate;
20
+ const isClickable = ch === 'telegram' && info?.status === 'paired' && onNavigate;
21
21
  let badge;
22
22
  if (!info) {
23
23
  badge = html`<a
@@ -0,0 +1,66 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useEffect } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+
5
+ const html = htm.bind(h);
6
+
7
+ export const ConfirmDialog = ({
8
+ visible = false,
9
+ title = "Confirm action",
10
+ message = "Are you sure you want to continue?",
11
+ confirmLabel = "Confirm",
12
+ cancelLabel = "Cancel",
13
+ onConfirm,
14
+ onCancel,
15
+ confirmTone = "primary",
16
+ }) => {
17
+ useEffect(() => {
18
+ if (!visible) return;
19
+
20
+ const handleKeydown = (event) => {
21
+ if (event.key === "Escape") {
22
+ onCancel?.();
23
+ }
24
+ };
25
+
26
+ window.addEventListener("keydown", handleKeydown);
27
+ return () => window.removeEventListener("keydown", handleKeydown);
28
+ }, [visible, onCancel]);
29
+
30
+ if (!visible) return null;
31
+
32
+ const confirmClass =
33
+ confirmTone === "warning"
34
+ ? "border border-yellow-500/45 text-yellow-300 bg-[linear-gradient(180deg,rgba(234,179,8,0.22)_0%,rgba(234,179,8,0.12)_100%)] shadow-[inset_0_0_0_1px_rgba(234,179,8,0.18)] hover:border-yellow-300/75 hover:text-yellow-200 hover:bg-[linear-gradient(180deg,rgba(234,179,8,0.3)_0%,rgba(234,179,8,0.16)_100%)] hover:shadow-[inset_0_0_0_1px_rgba(234,179,8,0.26),0_0_12px_rgba(234,179,8,0.16)]"
35
+ : "ac-btn-cyan";
36
+
37
+ return html`
38
+ <div
39
+ class="fixed inset-0 bg-black/70 flex items-center justify-center p-4 z-50"
40
+ onclick=${(event) => {
41
+ if (event.target === event.currentTarget) onCancel?.();
42
+ }}
43
+ >
44
+ <div class="bg-modal border border-border rounded-xl p-5 max-w-md w-full space-y-3">
45
+ <h2 class="text-base font-semibold">${title}</h2>
46
+ <p class="text-sm text-gray-400">${message}</p>
47
+ <div class="pt-1 flex items-center justify-end gap-2">
48
+ <button
49
+ type="button"
50
+ onclick=${onCancel}
51
+ class="px-4 py-2 rounded-lg text-sm ac-btn-secondary"
52
+ >
53
+ ${cancelLabel}
54
+ </button>
55
+ <button
56
+ type="button"
57
+ onclick=${onConfirm}
58
+ class="px-4 py-2 rounded-lg text-sm transition-all ${confirmClass}"
59
+ >
60
+ ${confirmLabel}
61
+ </button>
62
+ </div>
63
+ </div>
64
+ </div>
65
+ `;
66
+ };
@@ -328,7 +328,7 @@ export const CredentialsModal = ({ visible, onClose, onSaved }) => {
328
328
  </button>
329
329
  <button
330
330
  onclick=${onClose}
331
- class="px-4 py-2 rounded-lg text-sm ac-btn-cyan-ghost"
331
+ class="px-4 py-2 rounded-lg text-sm ac-btn-secondary"
332
332
  >
333
333
  Cancel
334
334
  </button>
@@ -7,14 +7,39 @@ import {
7
7
  updateOpenclaw,
8
8
  } from "../lib/api.js";
9
9
  import { showToast } from "./toast.js";
10
+ import { UpdateActionButton } from "./update-action-button.js";
11
+ import { ConfirmDialog } from "./confirm-dialog.js";
10
12
  const html = htm.bind(h);
11
13
 
12
- function VersionRow({ label, currentVersion, fetchVersion, applyUpdate, tagsUrl }) {
14
+ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate }) {
13
15
  const [checking, setChecking] = useState(false);
14
16
  const [version, setVersion] = useState(currentVersion || null);
15
17
  const [latestVersion, setLatestVersion] = useState(null);
16
18
  const [hasUpdate, setHasUpdate] = useState(false);
17
19
  const [error, setError] = useState("");
20
+ const [hasViewedChangelog, setHasViewedChangelog] = useState(false);
21
+ const [confirmWithoutChangelogOpen, setConfirmWithoutChangelogOpen] = useState(false);
22
+ const simulateUpdate = (() => {
23
+ try {
24
+ const params = new URLSearchParams(window.location.search);
25
+ return params.get("simulateUpdate") === "1";
26
+ } catch {
27
+ return false;
28
+ }
29
+ })();
30
+ const simulatedVersion = (() => {
31
+ if (!simulateUpdate) return null;
32
+ try {
33
+ const params = new URLSearchParams(window.location.search);
34
+ return params.get("simulateVersion") || "v0.0.0-preview";
35
+ } catch {
36
+ return "v0.0.0-preview";
37
+ }
38
+ })();
39
+ const effectiveHasUpdate = simulateUpdate || hasUpdate;
40
+ const effectiveLatestVersion = simulatedVersion || latestVersion;
41
+ const changelogUrl = "https://github.com/openclaw/openclaw/tags";
42
+ const showMobileUpdateRow = effectiveHasUpdate && effectiveLatestVersion;
18
43
 
19
44
  useEffect(() => {
20
45
  setVersion(currentVersion || null);
@@ -36,22 +61,30 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate, tagsUrl
36
61
  }
37
62
  };
38
63
  load();
39
- return () => { active = false; };
64
+ return () => {
65
+ active = false;
66
+ };
40
67
  }, []);
41
68
 
42
- const handleAction = async () => {
69
+ useEffect(() => {
70
+ if (!effectiveHasUpdate || !effectiveLatestVersion) {
71
+ setHasViewedChangelog(false);
72
+ return;
73
+ }
74
+ setHasViewedChangelog(false);
75
+ }, [effectiveHasUpdate, effectiveLatestVersion]);
76
+
77
+ const runAction = async () => {
43
78
  if (checking) return;
44
79
  setChecking(true);
45
80
  setError("");
46
81
  try {
47
- const data = hasUpdate
48
- ? await applyUpdate()
49
- : await fetchVersion(true);
82
+ const data = effectiveHasUpdate ? await applyUpdate() : await fetchVersion(true);
50
83
  setVersion(data.currentVersion || version);
51
84
  setLatestVersion(data.latestVersion || null);
52
85
  setHasUpdate(!!data.hasUpdate);
53
86
  setError(data.ok ? "" : data.error || "");
54
- if (hasUpdate) {
87
+ if (effectiveHasUpdate) {
55
88
  if (!data.ok) {
56
89
  showToast(data.error || `${label} update failed`, "error");
57
90
  } else if (data.updated || data.restarting) {
@@ -65,42 +98,117 @@ function VersionRow({ label, currentVersion, fetchVersion, applyUpdate, tagsUrl
65
98
  showToast(`Already at latest ${label} version`, "success");
66
99
  }
67
100
  } else if (data.hasUpdate && data.latestVersion) {
68
- showToast(`${label} update available: ${data.latestVersion}`, "warning");
101
+ showToast(
102
+ `${label} update available: ${data.latestVersion}`,
103
+ "warning",
104
+ );
69
105
  } else {
70
106
  showToast(`${label} is up to date`, "success");
71
107
  }
72
108
  } catch (err) {
73
- setError(err.message || (hasUpdate ? `Could not update ${label}` : "Could not check updates"));
74
- showToast(hasUpdate ? `Could not update ${label}` : "Could not check updates", "error");
109
+ setError(
110
+ err.message ||
111
+ (effectiveHasUpdate ? `Could not update ${label}` : "Could not check updates"),
112
+ );
113
+ showToast(
114
+ effectiveHasUpdate ? `Could not update ${label}` : "Could not check updates",
115
+ "error",
116
+ );
75
117
  }
76
118
  setChecking(false);
77
119
  };
78
120
 
121
+ const handleAction = () => {
122
+ if (checking) return;
123
+ if (effectiveHasUpdate && effectiveLatestVersion && !hasViewedChangelog) {
124
+ setConfirmWithoutChangelogOpen(true);
125
+ return;
126
+ }
127
+ runAction();
128
+ };
129
+
130
+ const handleConfirmWithoutChangelog = () => {
131
+ setConfirmWithoutChangelogOpen(false);
132
+ runAction();
133
+ };
134
+
79
135
  return html`
80
136
  <div class="flex items-center justify-between gap-3">
81
137
  <div class="min-w-0">
82
- <p class="text-sm text-gray-300 truncate">
83
- <span class="text-gray-500">${label}</span>${" "}${version ? `v${version}` : "..."}
138
+ <p class="text-xs text-gray-300 truncate">
139
+ <span class="text-gray-500">${label}</span>${" "}${version
140
+ ? `${version}`
141
+ : "..."}
84
142
  </p>
85
143
  ${error && html`<p class="text-xs text-yellow-500 mt-1">${error}</p>`}
86
144
  </div>
87
145
  <div class="flex items-center gap-2 shrink-0">
88
- ${hasUpdate && latestVersion && tagsUrl && html`
89
- <a href=${tagsUrl} target="_blank"
90
- class="text-xs text-yellow-500 hover:text-yellow-300 transition-colors"
91
- >${latestVersion} available</a>
146
+ ${effectiveHasUpdate && effectiveLatestVersion && html`
147
+ <a
148
+ href=${changelogUrl}
149
+ target="_blank"
150
+ rel="noreferrer"
151
+ onclick=${() => setHasViewedChangelog(true)}
152
+ class="hidden md:inline text-xs text-gray-500 hover:text-gray-300 transition-colors"
153
+ >View changelog</a
154
+ >
92
155
  `}
93
- <button
94
- onclick=${handleAction}
95
- disabled=${checking}
96
- 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 ${checking ? "opacity-50 cursor-not-allowed" : ""}"
97
- >
98
- ${checking
99
- ? hasUpdate ? "Updating..." : "Checking..."
100
- : hasUpdate ? "Update" : "Check updates"}
101
- </button>
156
+ ${showMobileUpdateRow
157
+ ? html`
158
+ <${UpdateActionButton}
159
+ onClick=${handleAction}
160
+ loading=${checking}
161
+ warning=${effectiveHasUpdate}
162
+ idleLabel=${effectiveHasUpdate
163
+ ? `Update to ${effectiveLatestVersion || "latest"}`
164
+ : "Check updates"}
165
+ loadingLabel=${effectiveHasUpdate ? "Updating..." : "Checking..."}
166
+ className="hidden md:inline-flex"
167
+ />
168
+ `
169
+ : html`
170
+ <${UpdateActionButton}
171
+ onClick=${handleAction}
172
+ loading=${checking}
173
+ warning=${effectiveHasUpdate}
174
+ idleLabel=${effectiveHasUpdate
175
+ ? `Update to ${effectiveLatestVersion || "latest"}`
176
+ : "Check updates"}
177
+ loadingLabel=${effectiveHasUpdate ? "Updating..." : "Checking..."}
178
+ />
179
+ `}
102
180
  </div>
103
181
  </div>
182
+ ${showMobileUpdateRow && html`
183
+ <div class="mt-2 md:hidden flex items-center gap-2">
184
+ <a
185
+ href=${changelogUrl}
186
+ target="_blank"
187
+ rel="noreferrer"
188
+ onclick=${() => setHasViewedChangelog(true)}
189
+ class="inline-flex items-center justify-center flex-1 h-9 text-xs rounded-lg border border-border text-gray-400 hover:text-gray-200 hover:border-gray-500 transition-colors"
190
+ >View changelog</a
191
+ >
192
+ <${UpdateActionButton}
193
+ onClick=${handleAction}
194
+ loading=${checking}
195
+ warning=${effectiveHasUpdate}
196
+ idleLabel=${`Update to ${effectiveLatestVersion || "latest"}`}
197
+ loadingLabel="Updating..."
198
+ className="flex-1 h-9 px-3"
199
+ />
200
+ </div>
201
+ `}
202
+ <${ConfirmDialog}
203
+ visible=${confirmWithoutChangelogOpen}
204
+ title="Update without changelog?"
205
+ message="Are you sure you want to update without viewing the changelog?"
206
+ confirmLabel=${`Update to ${effectiveLatestVersion || "latest"}`}
207
+ cancelLabel="Cancel"
208
+ confirmTone="warning"
209
+ onCancel=${() => setConfirmWithoutChangelogOpen(false)}
210
+ onConfirm=${handleConfirmWithoutChangelog}
211
+ />
104
212
  `;
105
213
  }
106
214
 
@@ -134,16 +242,14 @@ export function Gateway({ status, openclawVersion }) {
134
242
  >
135
243
  </div>
136
244
  </div>
137
- <button
138
- onclick=${handleRestart}
139
- disabled=${restarting || !status}
140
- 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 ${restarting ||
141
- !status
142
- ? "opacity-50 cursor-not-allowed"
143
- : ""}"
144
- >
145
- Restart
146
- </button>
245
+ <${UpdateActionButton}
246
+ onClick=${handleRestart}
247
+ disabled=${!status}
248
+ loading=${restarting}
249
+ warning=${false}
250
+ idleLabel="Restart"
251
+ loadingLabel="On it..."
252
+ />
147
253
  </div>
148
254
  <div class="mt-3 pt-3 border-t border-border">
149
255
  <${VersionRow}
@@ -151,7 +257,6 @@ export function Gateway({ status, openclawVersion }) {
151
257
  currentVersion=${openclawVersion}
152
258
  fetchVersion=${fetchOpenclawVersion}
153
259
  applyUpdate=${updateOpenclaw}
154
- tagsUrl="https://github.com/openclaw/openclaw/tags"
155
260
  />
156
261
  </div>
157
262
  </div>`;
@@ -12,6 +12,7 @@ import {
12
12
  getDefaultScopes,
13
13
  } from "./scope-picker.js";
14
14
  import { CredentialsModal } from "./credentials-modal.js";
15
+ import { ConfirmDialog } from "./confirm-dialog.js";
15
16
  import { showToast } from "./toast.js";
16
17
  const html = htm.bind(h);
17
18
 
@@ -22,6 +23,7 @@ export function Google({ gatewayStatus }) {
22
23
  const [apiStatus, setApiStatus] = useState({});
23
24
  const [checkingApis, setCheckingApis] = useState(false);
24
25
  const [modalOpen, setModalOpen] = useState(false);
26
+ const [disconnectDialogOpen, setDisconnectDialogOpen] = useState(false);
25
27
 
26
28
  const runApiCheck = useCallback(async () => {
27
29
  setApiStatus({});
@@ -98,12 +100,6 @@ export function Google({ gatewayStatus }) {
98
100
  const handleCheckApis = () => runApiCheck();
99
101
 
100
102
  const handleDisconnect = async () => {
101
- if (
102
- !confirm(
103
- "Disconnect Google account? Your agent will lose access to Gmail, Calendar, etc.",
104
- )
105
- )
106
- return;
107
103
  const data = await apiDisconnect();
108
104
  if (data.ok) {
109
105
  setGoogle({
@@ -188,7 +184,7 @@ export function Google({ gatewayStatus }) {
188
184
  </button>
189
185
  </div>
190
186
  <button
191
- onclick=${handleDisconnect}
187
+ onclick=${() => setDisconnectDialogOpen(true)}
192
188
  class="text-xs text-red-400/60 hover:text-red-400"
193
189
  >
194
190
  Disconnect
@@ -216,5 +212,17 @@ export function Google({ gatewayStatus }) {
216
212
  onClose=${() => setModalOpen(false)}
217
213
  onSaved=${refresh}
218
214
  />
215
+ <${ConfirmDialog}
216
+ visible=${disconnectDialogOpen}
217
+ title="Disconnect Google account?"
218
+ message="Your agent will lose access to Gmail, Calendar, and other Google Workspace services until you reconnect."
219
+ confirmLabel="Disconnect"
220
+ cancelLabel="Cancel"
221
+ onCancel=${() => setDisconnectDialogOpen(false)}
222
+ onConfirm=${async () => {
223
+ setDisconnectDialogOpen(false);
224
+ await handleDisconnect();
225
+ }}
226
+ />
219
227
  `;
220
228
  }
@@ -36,6 +36,7 @@ export const WelcomeFormStep = ({
36
36
  goBack,
37
37
  goNext,
38
38
  loading,
39
+ githubStepLoading,
39
40
  allValid,
40
41
  handleSubmit,
41
42
  }) => {
@@ -276,10 +277,12 @@ export const WelcomeFormStep = ({
276
277
  : html`<div class="w-full"></div>`}
277
278
  <button
278
279
  onclick=${goNext}
279
- disabled=${!currentGroupValid}
280
+ disabled=${!currentGroupValid || githubStepLoading}
280
281
  class="w-full text-sm font-medium px-4 py-2 rounded-xl transition-all ac-btn-cyan"
281
282
  >
282
- Next
283
+ ${activeGroup.id === "github" && githubStepLoading
284
+ ? "Checking..."
285
+ : "Next"}
283
286
  </button>
284
287
  `
285
288
  : html`
@@ -78,7 +78,7 @@ export const WelcomeSetupStep = ({ error, loading, onRetry, onBack }) => {
78
78
  const currentTip = kSetupTips[tipIndex];
79
79
 
80
80
  return html`
81
- <div class="min-h-[320px] py-4 flex flex-col">
81
+ <div class="relative min-h-[320px] pt-4 pb-20 flex">
82
82
  <div
83
83
  class="flex-1 flex flex-col items-center justify-center text-center gap-4"
84
84
  >
@@ -107,7 +107,7 @@ export const WelcomeSetupStep = ({ error, loading, onRetry, onBack }) => {
107
107
  <p class="text-sm text-gray-500">This could take 10-15 seconds</p>
108
108
  </div>
109
109
  <div
110
- class="mt-3 bg-black/20 border border-border rounded-lg px-3 py-2 text-xs text-gray-500"
110
+ class="absolute bottom-3 left-3 right-3 bg-black/20 border border-border rounded-lg px-3 py-2 text-xs text-gray-500"
111
111
  >
112
112
  <span class="text-gray-400">${currentTip.label}: </span>
113
113
  ${currentTip.text}
@@ -0,0 +1,66 @@
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
+ export const UpdateActionButton = ({
7
+ onClick,
8
+ disabled = false,
9
+ loading = false,
10
+ warning = false,
11
+ idleLabel = "Check updates",
12
+ loadingLabel = "Checking...",
13
+ className = "",
14
+ }) => {
15
+ const isInteractive = !loading && !disabled;
16
+ const toneClass = warning
17
+ ? isInteractive
18
+ ? "border-yellow-500/35 text-yellow-400 bg-yellow-500/10 hover:border-yellow-400/60 hover:text-yellow-300 hover:bg-yellow-500/15"
19
+ : "border-yellow-500/35 text-yellow-400 bg-yellow-500/10"
20
+ : isInteractive
21
+ ? "border-border text-gray-500 hover:text-gray-300 hover:border-gray-500"
22
+ : "border-border text-gray-500";
23
+ const loadingClass = loading
24
+ ? `cursor-not-allowed ${warning
25
+ ? "opacity-90 animate-pulse shadow-[0_0_0_1px_rgba(234,179,8,0.22),0_0_18px_rgba(234,179,8,0.12)]"
26
+ : "opacity-80"}`
27
+ : "";
28
+
29
+ return html`
30
+ <button
31
+ onclick=${onClick}
32
+ disabled=${disabled || loading}
33
+ class="inline-flex items-center justify-center h-7 text-xs leading-none px-2.5 py-1 rounded-lg border transition-colors whitespace-nowrap ${toneClass} ${loadingClass} ${className}"
34
+ >
35
+ ${loading
36
+ ? html`
37
+ <span class="inline-flex items-center gap-1.5 leading-none">
38
+ <svg
39
+ class="animate-spin"
40
+ width="12"
41
+ height="12"
42
+ viewBox="0 0 24 24"
43
+ fill="none"
44
+ aria-hidden="true"
45
+ >
46
+ <circle
47
+ class="opacity-30"
48
+ cx="12"
49
+ cy="12"
50
+ r="9"
51
+ stroke="currentColor"
52
+ stroke-width="3"
53
+ />
54
+ <path
55
+ class="opacity-90"
56
+ fill="currentColor"
57
+ d="M12 3a9 9 0 0 1 9 9h-3a6 6 0 0 0-6-6V3z"
58
+ />
59
+ </svg>
60
+ ${loadingLabel}
61
+ </span>
62
+ `
63
+ : idleLabel}
64
+ </button>
65
+ `;
66
+ };
@@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import {
5
5
  runOnboard,
6
+ verifyGithubOnboardingRepo,
6
7
  fetchModels,
7
8
  fetchCodexStatus,
8
9
  disconnectCodex,
@@ -58,6 +59,7 @@ export const Welcome = ({ onComplete }) => {
58
59
  const [codexAuthStarted, setCodexAuthStarted] = useState(false);
59
60
  const [codexAuthWaiting, setCodexAuthWaiting] = useState(false);
60
61
  const [loading, setLoading] = useState(false);
62
+ const [githubStepLoading, setGithubStepLoading] = useState(false);
61
63
  const [error, setError] = useState(null);
62
64
  const codexPopupPollRef = useRef(null);
63
65
 
@@ -360,8 +362,27 @@ export const Welcome = ({ onComplete }) => {
360
362
  setStep(kWelcomeGroups.length - 1);
361
363
  };
362
364
 
363
- const goNext = () => {
365
+ const goNext = async () => {
364
366
  if (!activeGroup || !currentGroupValid) return;
367
+ if (activeGroup.id === "github") {
368
+ setGithubStepLoading(true);
369
+ setError(null);
370
+ try {
371
+ const result = await verifyGithubOnboardingRepo(
372
+ vals.GITHUB_WORKSPACE_REPO,
373
+ vals.GITHUB_TOKEN,
374
+ );
375
+ if (!result?.ok) {
376
+ setError(result?.error || "GitHub verification failed");
377
+ return;
378
+ }
379
+ } catch (err) {
380
+ setError(err?.message || "GitHub verification failed");
381
+ return;
382
+ } finally {
383
+ setGithubStepLoading(false);
384
+ }
385
+ }
365
386
  setStep((prev) => Math.min(kWelcomeGroups.length - 1, prev + 1));
366
387
  };
367
388
 
@@ -438,6 +459,7 @@ export const Welcome = ({ onComplete }) => {
438
459
  goBack=${goBack}
439
460
  goNext=${goNext}
440
461
  loading=${loading}
462
+ githubStepLoading=${githubStepLoading}
441
463
  allValid=${allValid}
442
464
  handleSubmit=${handleSubmit}
443
465
  />
@@ -167,6 +167,15 @@ export async function runOnboard(vars, modelKey) {
167
167
  return res.json();
168
168
  }
169
169
 
170
+ export async function verifyGithubOnboardingRepo(repo, token) {
171
+ const res = await authFetch('/api/onboard/github/verify', {
172
+ method: 'POST',
173
+ headers: { 'Content-Type': 'application/json' },
174
+ body: JSON.stringify({ repo, token }),
175
+ });
176
+ return res.json();
177
+ }
178
+
170
179
  export const fetchModels = async () => {
171
180
  const res = await authFetch('/api/models');
172
181
  return res.json();
@@ -1,48 +1,114 @@
1
- const ensureGithubRepoAccessible = async ({ repoUrl, repoName, githubToken }) => {
2
- const ghHeaders = {
3
- Authorization: `token ${githubToken}`,
4
- "User-Agent": "openclaw-railway",
5
- Accept: "application/vnd.github+json",
6
- };
1
+ const buildGithubHeaders = (githubToken) => ({
2
+ Authorization: `token ${githubToken}`,
3
+ "User-Agent": "openclaw-railway",
4
+ Accept: "application/vnd.github+json",
5
+ });
7
6
 
7
+ const parseGithubErrorMessage = async (response) => {
8
8
  try {
9
- const checkRes = await fetch(`https://api.github.com/repos/${repoUrl}`, {
9
+ const payload = await response.json();
10
+ if (typeof payload?.message === "string" && payload.message.trim()) {
11
+ return payload.message.trim();
12
+ }
13
+ } catch {}
14
+ return response.statusText || `HTTP ${response.status}`;
15
+ };
16
+
17
+ const verifyGithubRepoForOnboarding = async ({ repoUrl, githubToken }) => {
18
+ const ghHeaders = buildGithubHeaders(githubToken);
19
+ const [repoOwner] = String(repoUrl || "").split("/", 1);
20
+
21
+ try {
22
+ const userRes = await fetch("https://api.github.com/user", {
10
23
  headers: ghHeaders,
11
24
  });
25
+ if (!userRes.ok) {
26
+ const details = await parseGithubErrorMessage(userRes);
27
+ return {
28
+ ok: false,
29
+ status: 400,
30
+ error: `Cannot verify GitHub token: ${details}`,
31
+ };
32
+ }
33
+ const authedUser = await userRes.json().catch(() => ({}));
34
+ const authedLogin = String(authedUser?.login || "").trim();
35
+ if (
36
+ repoOwner &&
37
+ authedLogin &&
38
+ repoOwner.toLowerCase() !== authedLogin.toLowerCase()
39
+ ) {
40
+ return {
41
+ ok: false,
42
+ status: 400,
43
+ error: `Workspace repo owner must match your token user "${authedLogin}"`,
44
+ };
45
+ }
12
46
 
47
+ const checkRes = await fetch(`https://api.github.com/repos/${repoUrl}`, {
48
+ headers: ghHeaders,
49
+ });
13
50
  if (checkRes.status === 404) {
14
- console.log(`[onboard] Creating repo ${repoUrl}...`);
15
- const createRes = await fetch("https://api.github.com/user/repos", {
16
- method: "POST",
17
- headers: { ...ghHeaders, "Content-Type": "application/json" },
18
- body: JSON.stringify({
19
- name: repoName,
20
- private: true,
21
- auto_init: false,
22
- }),
23
- });
24
- if (!createRes.ok) {
25
- const err = await createRes.json().catch(() => ({}));
26
- return {
27
- ok: false,
28
- status: 400,
29
- error: `Failed to create repo: ${err.message || createRes.statusText}`,
30
- };
31
- }
32
- console.log(`[onboard] Repo ${repoUrl} created`);
33
51
  return { ok: true };
34
52
  }
53
+ if (checkRes.ok) {
54
+ return {
55
+ ok: false,
56
+ status: 400,
57
+ error: `Repository "${repoUrl}" already exists.`,
58
+ };
59
+ }
35
60
 
36
- if (checkRes.ok) return { ok: true };
37
-
61
+ const details = await parseGithubErrorMessage(checkRes);
38
62
  return {
39
63
  ok: false,
40
64
  status: 400,
41
- error: `Cannot access repo "${repoUrl}" — check your token has the "repo" scope`,
65
+ error: `Cannot verify repo "${repoUrl}": ${details}`,
42
66
  };
67
+ } catch (e) {
68
+ return {
69
+ ok: false,
70
+ status: 400,
71
+ error: `GitHub verification error: ${e.message}`,
72
+ };
73
+ }
74
+ };
75
+
76
+ const ensureGithubRepoAccessible = async ({
77
+ repoUrl,
78
+ repoName,
79
+ githubToken,
80
+ }) => {
81
+ const ghHeaders = buildGithubHeaders(githubToken);
82
+ const verification = await verifyGithubRepoForOnboarding({
83
+ repoUrl,
84
+ githubToken,
85
+ });
86
+ if (!verification.ok) return verification;
87
+
88
+ try {
89
+ console.log(`[onboard] Creating repo ${repoUrl}...`);
90
+ const createRes = await fetch("https://api.github.com/user/repos", {
91
+ method: "POST",
92
+ headers: { ...ghHeaders, "Content-Type": "application/json" },
93
+ body: JSON.stringify({
94
+ name: repoName,
95
+ private: true,
96
+ auto_init: false,
97
+ }),
98
+ });
99
+ if (!createRes.ok) {
100
+ const details = await parseGithubErrorMessage(createRes);
101
+ return {
102
+ ok: false,
103
+ status: 400,
104
+ error: `Failed to create repo: ${details}`,
105
+ };
106
+ }
107
+ console.log(`[onboard] Repo ${repoUrl} created`);
108
+ return { ok: true };
43
109
  } catch (e) {
44
110
  return { ok: false, status: 400, error: `GitHub error: ${e.message}` };
45
111
  }
46
112
  };
47
113
 
48
- module.exports = { ensureGithubRepoAccessible };
114
+ module.exports = { ensureGithubRepoAccessible, verifyGithubRepoForOnboarding };
@@ -1,10 +1,22 @@
1
1
  const path = require("path");
2
2
  const { kSetupDir, kRootDir } = require("../constants");
3
3
  const { validateOnboardingInput } = require("./validation");
4
- const { ensureGithubRepoAccessible } = require("./github");
5
- const { buildOnboardArgs, writeSanitizedOpenclawConfig } = require("./openclaw");
6
- const { installControlUiSkill, syncBootstrapPromptFiles } = require("./workspace");
7
- const { installHourlyGitSyncScript, installHourlyGitSyncCron } = require("./cron");
4
+ const {
5
+ ensureGithubRepoAccessible,
6
+ verifyGithubRepoForOnboarding,
7
+ } = require("./github");
8
+ const {
9
+ buildOnboardArgs,
10
+ writeSanitizedOpenclawConfig,
11
+ } = require("./openclaw");
12
+ const {
13
+ installControlUiSkill,
14
+ syncBootstrapPromptFiles,
15
+ } = require("./workspace");
16
+ const {
17
+ installHourlyGitSyncScript,
18
+ installHourlyGitSyncCron,
19
+ } = require("./cron");
8
20
 
9
21
  const createOnboardingService = ({
10
22
  fs,
@@ -22,6 +34,15 @@ const createOnboardingService = ({
22
34
  }) => {
23
35
  const { OPENCLAW_DIR, WORKSPACE_DIR } = constants;
24
36
 
37
+ const verifyGithubSetup = async ({
38
+ githubRepoInput,
39
+ githubToken,
40
+ resolveGithubRepoUrl,
41
+ }) => {
42
+ const repoUrl = resolveGithubRepoUrl(githubRepoInput);
43
+ return verifyGithubRepoForOnboarding({ repoUrl, githubToken });
44
+ };
45
+
25
46
  const completeOnboarding = async ({ req, vars, modelKey }) => {
26
47
  const validation = validateOnboardingInput({
27
48
  vars,
@@ -30,14 +51,24 @@ const createOnboardingService = ({
30
51
  hasCodexOauthProfile,
31
52
  });
32
53
  if (!validation.ok) {
33
- return { status: validation.status, body: { ok: false, error: validation.error } };
54
+ return {
55
+ status: validation.status,
56
+ body: { ok: false, error: validation.error },
57
+ };
34
58
  }
35
59
 
36
- const { varMap, githubToken, githubRepoInput, selectedProvider, hasCodexOauth } =
37
- validation.data;
60
+ const {
61
+ varMap,
62
+ githubToken,
63
+ githubRepoInput,
64
+ selectedProvider,
65
+ hasCodexOauth,
66
+ } = validation.data;
38
67
 
39
68
  const repoUrl = resolveGithubRepoUrl(githubRepoInput);
40
- const varsToSave = [...vars.filter((v) => v.value && v.key !== "GITHUB_WORKSPACE_REPO")];
69
+ const varsToSave = [
70
+ ...vars.filter((v) => v.value && v.key !== "GITHUB_WORKSPACE_REPO"),
71
+ ];
41
72
  varsToSave.push({ key: "GITHUB_WORKSPACE_REPO", value: repoUrl });
42
73
  writeEnvFile(varsToSave);
43
74
  reloadEnv();
@@ -50,22 +81,32 @@ const createOnboardingService = ({
50
81
  githubToken,
51
82
  });
52
83
  if (!repoCheck.ok) {
53
- return { status: repoCheck.status, body: { ok: false, error: repoCheck.error } };
84
+ return {
85
+ status: repoCheck.status,
86
+ body: { ok: false, error: repoCheck.error },
87
+ };
54
88
  }
55
89
 
56
90
  fs.mkdirSync(OPENCLAW_DIR, { recursive: true });
57
91
  fs.mkdirSync(WORKSPACE_DIR, { recursive: true });
58
- syncBootstrapPromptFiles({ fs, workspaceDir: WORKSPACE_DIR, baseUrl: getBaseUrl(req) });
92
+ syncBootstrapPromptFiles({
93
+ fs,
94
+ workspaceDir: WORKSPACE_DIR,
95
+ baseUrl: getBaseUrl(req),
96
+ });
59
97
 
60
98
  if (!fs.existsSync(`${OPENCLAW_DIR}/.git`)) {
61
99
  await shellCmd(
62
- `cd ${OPENCLAW_DIR} && git init -b main && git remote add origin "${remoteUrl}" && git config user.email "agent@openclaw.ai" && git config user.name "OpenClaw Agent"`,
100
+ `cd ${OPENCLAW_DIR} && git init -b main && git remote add origin "${remoteUrl}" && git config user.email "agent@alphaclaw.md" && git config user.name "AlphaClaw Agent"`,
63
101
  );
64
102
  console.log("[onboard] Git initialized");
65
103
  }
66
104
 
67
105
  if (!fs.existsSync(`${OPENCLAW_DIR}/.gitignore`)) {
68
- fs.copyFileSync(path.join(kSetupDir, "gitignore"), `${OPENCLAW_DIR}/.gitignore`);
106
+ fs.copyFileSync(
107
+ path.join(kSetupDir, "gitignore"),
108
+ `${OPENCLAW_DIR}/.gitignore`,
109
+ );
69
110
  }
70
111
 
71
112
  const onboardArgs = buildOnboardArgs({
@@ -74,14 +115,17 @@ const createOnboardingService = ({
74
115
  hasCodexOauth,
75
116
  workspaceDir: WORKSPACE_DIR,
76
117
  });
77
- await shellCmd(`openclaw onboard ${onboardArgs.map((a) => `"${a}"`).join(" ")}`, {
78
- env: {
79
- ...process.env,
80
- OPENCLAW_HOME: kRootDir,
81
- OPENCLAW_CONFIG_PATH: `${OPENCLAW_DIR}/openclaw.json`,
118
+ await shellCmd(
119
+ `openclaw onboard ${onboardArgs.map((a) => `"${a}"`).join(" ")}`,
120
+ {
121
+ env: {
122
+ ...process.env,
123
+ OPENCLAW_HOME: kRootDir,
124
+ OPENCLAW_CONFIG_PATH: `${OPENCLAW_DIR}/openclaw.json`,
125
+ },
126
+ timeout: 120000,
82
127
  },
83
- timeout: 120000,
84
- });
128
+ );
85
129
  console.log("[onboard] Onboard complete");
86
130
 
87
131
  await shellCmd(`openclaw models set "${modelKey}"`, {
@@ -89,7 +133,9 @@ const createOnboardingService = ({
89
133
  timeout: 30000,
90
134
  }).catch((e) => {
91
135
  console.error("[onboard] Failed to set model:", e.message);
92
- throw new Error(`Onboarding completed but failed to set model "${modelKey}"`);
136
+ throw new Error(
137
+ `Onboarding completed but failed to set model "${modelKey}"`,
138
+ );
93
139
  });
94
140
 
95
141
  try {
@@ -99,28 +145,33 @@ const createOnboardingService = ({
99
145
  writeSanitizedOpenclawConfig({ fs, openclawDir: OPENCLAW_DIR, varMap });
100
146
  ensureGatewayProxyConfig(getBaseUrl(req));
101
147
 
102
- installControlUiSkill({ fs, openclawDir: OPENCLAW_DIR, baseUrl: getBaseUrl(req) });
148
+ installControlUiSkill({
149
+ fs,
150
+ openclawDir: OPENCLAW_DIR,
151
+ baseUrl: getBaseUrl(req),
152
+ });
103
153
 
104
- await shellCmd(
105
- `alphaclaw git-sync -m "initial setup"`,
106
- {
154
+ installHourlyGitSyncScript({ fs, openclawDir: OPENCLAW_DIR });
155
+ await installHourlyGitSyncCron({ fs, openclawDir: OPENCLAW_DIR });
156
+
157
+ try {
158
+ await shellCmd(`alphaclaw git-sync -m "initial setup"`, {
107
159
  timeout: 30000,
108
160
  env: {
109
161
  ...process.env,
110
162
  GITHUB_TOKEN: githubToken,
111
163
  },
112
- },
113
- ).catch((e) => console.error("[onboard] Git push error:", e.message));
114
- console.log("[onboard] Initial state committed and pushed");
115
-
116
- installHourlyGitSyncScript({ fs, openclawDir: OPENCLAW_DIR });
117
- await installHourlyGitSyncCron({ fs, openclawDir: OPENCLAW_DIR });
164
+ });
165
+ console.log("[onboard] Initial state committed and pushed");
166
+ } catch (e) {
167
+ console.error("[onboard] Git push error:", e.message);
168
+ }
118
169
 
119
170
  startGateway();
120
171
  return { status: 200, body: { ok: true } };
121
172
  };
122
173
 
123
- return { completeOnboarding };
174
+ return { completeOnboarding, verifyGithubSetup };
124
175
  };
125
176
 
126
177
  module.exports = { createOnboardingService };
@@ -111,6 +111,42 @@ const registerOnboardingRoutes = ({
111
111
  res.status(500).json({ ok: false, error: sanitizeOnboardingError(err) });
112
112
  }
113
113
  });
114
+
115
+ app.post("/api/onboard/github/verify", async (req, res) => {
116
+ if (isOnboarded()) {
117
+ return res.json({ ok: false, error: "Already onboarded" });
118
+ }
119
+
120
+ try {
121
+ const githubRepoInput = String(req.body?.repo || "").trim();
122
+ const githubToken = String(req.body?.token || "").trim();
123
+ if (!githubRepoInput || !githubToken) {
124
+ return res
125
+ .status(400)
126
+ .json({
127
+ ok: false,
128
+ error: "GitHub token and workspace repo are required",
129
+ });
130
+ }
131
+
132
+ const result = await onboardingService.verifyGithubSetup({
133
+ githubRepoInput,
134
+ githubToken,
135
+ resolveGithubRepoUrl,
136
+ });
137
+ if (!result.ok) {
138
+ return res
139
+ .status(result.status || 400)
140
+ .json({ ok: false, error: result.error });
141
+ }
142
+ return res.json({ ok: true });
143
+ } catch (err) {
144
+ console.error("[onboard] GitHub verify error:", err);
145
+ return res
146
+ .status(500)
147
+ .json({ ok: false, error: sanitizeOnboardingError(err) });
148
+ }
149
+ });
114
150
  };
115
151
 
116
152
  module.exports = { registerOnboardingRoutes };
@@ -6,11 +6,11 @@ AlphaClaw UI: `{{SETUP_UI_URL}}`
6
6
 
7
7
  ### Tabs
8
8
 
9
- | Tab | URL | What it manages |
10
- | ------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
- | General | `{{SETUP_UI_URL}}#general` | Gateway status & restart, channel health (Telegram/Discord), pending pairings, feature health (Embeddings/Audio), Google Workspace connection, repo auto-sync schedule, OpenClaw dashboard |
12
- | Providers | `{{SETUP_UI_URL}}#providers` | AI provider credentials (Anthropic, OpenAI, Gemini, Mistral, Voyage, Groq, Deepgram), feature capabilities, Codex OAuth |
13
- | Envars | `{{SETUP_UI_URL}}#envars` | View/edit/add environment variables (saved to `/data/.env`), gateway restart to apply changes |
9
+ | Tab | URL | What it manages |
10
+ | --------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
11
+ | General | `{{SETUP_UI_URL}}#general` | Gateway status & restart, channel health (Telegram/Discord), pending pairings, feature health (Embeddings/Audio), Google Workspace connection, repo auto-sync schedule, OpenClaw dashboard |
12
+ | Providers | `{{SETUP_UI_URL}}#providers` | AI provider credentials (Anthropic, OpenAI, Gemini, Mistral, Voyage, Groq, Deepgram), feature capabilities, Codex OAuth |
13
+ | Envars | `{{SETUP_UI_URL}}#envars` | View/edit/add environment variables (saved to `/data/.env`), gateway restart to apply changes |
14
14
 
15
15
  ### Environment variables
16
16
 
@@ -4,6 +4,8 @@
4
4
  # Whitelist specific files/dirs.
5
5
  !workspace/
6
6
  !workspace/**
7
+ workspace/.openclaw/
8
+ workspace/.openclaw/**
7
9
  !skills/
8
10
  !skills/**
9
11
  !cron/
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
7
  "description": "Setup UI, gateway manager, and onboarding wrapper for OpenClaw",
8
8
  "bin": {
9
- "alphaclaw": "./bin/alphaclaw.js"
9
+ "alphaclaw": "bin/alphaclaw.js"
10
10
  },
11
11
  "files": [
12
12
  "bin/",