@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,541 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useState, useEffect, useRef } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import {
5
+ runOnboard,
6
+ fetchModels,
7
+ fetchCodexStatus,
8
+ disconnectCodex,
9
+ exchangeCodexOAuth,
10
+ } from "../lib/api.js";
11
+ import {
12
+ getModelProvider,
13
+ getFeaturedModels,
14
+ getVisibleAiFieldKeys,
15
+ kAllAiAuthFields,
16
+ } from "../lib/model-config.js";
17
+ const html = htm.bind(h);
18
+
19
+ const kGroups = [
20
+ {
21
+ id: "ai",
22
+ title: "Primary Agent Model",
23
+ description: "Choose your main model and authenticate its provider",
24
+ fields: kAllAiAuthFields,
25
+ validate: (vals, ctx = {}) => !!(vals.MODEL_KEY && ctx.hasAi),
26
+ },
27
+ {
28
+ id: "github",
29
+ title: "GitHub",
30
+ description: "Backs up your agent's config and workspace",
31
+ fields: [
32
+ {
33
+ key: "GITHUB_TOKEN",
34
+ label: "Personal Access Token",
35
+ hint: html`Create a classic PAT at${" "}<a
36
+ href="https://github.com/settings/tokens"
37
+ target="_blank"
38
+ class="text-blue-400 hover:underline"
39
+ >github.com/settings/tokens</a
40
+ >${" "}with${" "}<code class="text-xs bg-black/30 px-1 rounded">repo</code>${" "}scope`,
41
+ placeholder: "ghp_...",
42
+ },
43
+ {
44
+ key: "GITHUB_WORKSPACE_REPO",
45
+ label: "Workspace Repo",
46
+ hint: "A new private repo will be created for you",
47
+ placeholder: "username/my-agent",
48
+ isText: true,
49
+ },
50
+ ],
51
+ validate: (vals) => !!(vals.GITHUB_TOKEN && vals.GITHUB_WORKSPACE_REPO),
52
+ },
53
+ {
54
+ id: "channels",
55
+ title: "Channels",
56
+ description: "At least one is required to talk to your agent",
57
+ fields: [
58
+ {
59
+ key: "TELEGRAM_BOT_TOKEN",
60
+ label: "Telegram Bot Token",
61
+ hint: html`From${" "}<a
62
+ href="https://t.me/BotFather"
63
+ target="_blank"
64
+ class="text-blue-400 hover:underline"
65
+ >@BotFather</a
66
+ >${" "}·${" "}<a
67
+ href="https://docs.openclaw.ai/channels/telegram"
68
+ target="_blank"
69
+ class="text-blue-400 hover:underline"
70
+ >full guide</a
71
+ >`,
72
+ placeholder: "123456789:AAH...",
73
+ },
74
+ {
75
+ key: "DISCORD_BOT_TOKEN",
76
+ label: "Discord Bot Token",
77
+ hint: html`From${" "}<a
78
+ href="https://discord.com/developers/applications"
79
+ target="_blank"
80
+ class="text-blue-400 hover:underline"
81
+ >Developer Portal</a
82
+ >${" "}·${" "}<a
83
+ href="https://docs.openclaw.ai/channels/discord"
84
+ target="_blank"
85
+ class="text-blue-400 hover:underline"
86
+ >full guide</a
87
+ >`,
88
+ placeholder: "MTQ3...",
89
+ },
90
+ ],
91
+ validate: (vals) => !!(vals.TELEGRAM_BOT_TOKEN || vals.DISCORD_BOT_TOKEN),
92
+ },
93
+ {
94
+ id: "tools",
95
+ title: "Tools (optional)",
96
+ description: "Enable extra capabilities for your agent",
97
+ fields: [
98
+ {
99
+ key: "BRAVE_API_KEY",
100
+ label: "Brave Search API Key",
101
+ hint: html`From${" "}<a
102
+ href="https://brave.com/search/api/"
103
+ target="_blank"
104
+ class="text-blue-400 hover:underline"
105
+ >brave.com/search/api</a
106
+ >${" "}-${" "}free tier available`,
107
+ placeholder: "BSA...",
108
+ },
109
+ ],
110
+ validate: () => true,
111
+ },
112
+ ];
113
+
114
+ export const Welcome = ({ onComplete }) => {
115
+ const [vals, setVals] = useState(() => {
116
+ try {
117
+ return JSON.parse(localStorage.getItem("openclaw_setup") || "{}");
118
+ } catch {
119
+ return {};
120
+ }
121
+ });
122
+ const [models, setModels] = useState([]);
123
+ const [modelsLoading, setModelsLoading] = useState(true);
124
+ const [modelsError, setModelsError] = useState(null);
125
+ const [showAllModels, setShowAllModels] = useState(false);
126
+ const [codexStatus, setCodexStatus] = useState({ connected: false });
127
+ const [codexLoading, setCodexLoading] = useState(true);
128
+ const [codexManualInput, setCodexManualInput] = useState("");
129
+ const [codexExchanging, setCodexExchanging] = useState(false);
130
+ const [codexAuthStarted, setCodexAuthStarted] = useState(false);
131
+ const [codexAuthWaiting, setCodexAuthWaiting] = useState(false);
132
+ const [loading, setLoading] = useState(false);
133
+ const [error, setError] = useState(null);
134
+ const codexPopupPollRef = useRef(null);
135
+
136
+ useEffect(() => {
137
+ localStorage.setItem("openclaw_setup", JSON.stringify(vals));
138
+ }, [vals]);
139
+
140
+ useEffect(() => {
141
+ fetchModels()
142
+ .then((result) => {
143
+ const list = Array.isArray(result.models) ? result.models : [];
144
+ const featured = getFeaturedModels(list);
145
+ setModels(list);
146
+ if (!vals.MODEL_KEY && list.length > 0) {
147
+ const defaultModel = featured[0] || list[0];
148
+ setVals((prev) => ({ ...prev, MODEL_KEY: defaultModel.key }));
149
+ }
150
+ })
151
+ .catch(() => setModelsError("Failed to load models"))
152
+ .finally(() => setModelsLoading(false));
153
+ }, []);
154
+
155
+ const refreshCodexStatus = async () => {
156
+ try {
157
+ const status = await fetchCodexStatus();
158
+ setCodexStatus(status);
159
+ if (status?.connected) {
160
+ setCodexAuthStarted(false);
161
+ setCodexAuthWaiting(false);
162
+ }
163
+ } catch {
164
+ setCodexStatus({ connected: false });
165
+ } finally {
166
+ setCodexLoading(false);
167
+ }
168
+ };
169
+
170
+ useEffect(() => {
171
+ refreshCodexStatus();
172
+ }, []);
173
+
174
+ useEffect(() => {
175
+ const onMessage = async (e) => {
176
+ if (e.data?.codex === "success") {
177
+ await refreshCodexStatus();
178
+ }
179
+ if (e.data?.codex === "error") {
180
+ setError(`Codex auth failed: ${e.data.message || "unknown error"}`);
181
+ }
182
+ };
183
+ window.addEventListener("message", onMessage);
184
+ return () => window.removeEventListener("message", onMessage);
185
+ }, []);
186
+
187
+ useEffect(
188
+ () => () => {
189
+ if (codexPopupPollRef.current) {
190
+ clearInterval(codexPopupPollRef.current);
191
+ codexPopupPollRef.current = null;
192
+ }
193
+ },
194
+ [],
195
+ );
196
+
197
+ const set = (key, value) => setVals((prev) => ({ ...prev, [key]: value }));
198
+
199
+ const selectedProvider = getModelProvider(vals.MODEL_KEY);
200
+ const featuredModels = getFeaturedModels(models);
201
+ const baseModelOptions = showAllModels
202
+ ? models
203
+ : featuredModels.length > 0
204
+ ? featuredModels
205
+ : models;
206
+ const selectedModelOption = models.find(
207
+ (model) => model.key === vals.MODEL_KEY,
208
+ );
209
+ const modelOptions =
210
+ selectedModelOption &&
211
+ !baseModelOptions.some((model) => model.key === selectedModelOption.key)
212
+ ? [...baseModelOptions, selectedModelOption]
213
+ : baseModelOptions;
214
+ const canToggleFullCatalog =
215
+ featuredModels.length > 0 && models.length > featuredModels.length;
216
+ const visibleAiFieldKeys = getVisibleAiFieldKeys(selectedProvider);
217
+ const hasAi =
218
+ selectedProvider === "anthropic"
219
+ ? !!(vals.ANTHROPIC_API_KEY || vals.ANTHROPIC_TOKEN)
220
+ : selectedProvider === "openai"
221
+ ? !!vals.OPENAI_API_KEY
222
+ : selectedProvider === "google"
223
+ ? !!vals.GEMINI_API_KEY
224
+ : selectedProvider === "openai-codex"
225
+ ? !!(codexStatus.connected || vals.OPENAI_API_KEY)
226
+ : false;
227
+
228
+ const allValid = kGroups.every((g) => g.validate(vals, { hasAi }));
229
+
230
+ const startCodexAuth = () => {
231
+ if (codexStatus.connected) return;
232
+ setCodexAuthStarted(true);
233
+ setCodexAuthWaiting(true);
234
+ const authUrl = "/auth/codex/start";
235
+ const popup = window.open(
236
+ authUrl,
237
+ "codex-auth",
238
+ "popup=yes,width=640,height=780",
239
+ );
240
+ if (!popup || popup.closed) {
241
+ setCodexAuthWaiting(false);
242
+ window.location.href = authUrl;
243
+ return;
244
+ }
245
+ if (codexPopupPollRef.current) {
246
+ clearInterval(codexPopupPollRef.current);
247
+ }
248
+ codexPopupPollRef.current = setInterval(() => {
249
+ if (popup.closed) {
250
+ clearInterval(codexPopupPollRef.current);
251
+ codexPopupPollRef.current = null;
252
+ setCodexAuthWaiting(false);
253
+ }
254
+ }, 500);
255
+ };
256
+
257
+ const completeCodexAuth = async () => {
258
+ if (!codexManualInput.trim() || codexExchanging) return;
259
+ setCodexExchanging(true);
260
+ setError(null);
261
+ try {
262
+ const result = await exchangeCodexOAuth(codexManualInput.trim());
263
+ if (!result.ok)
264
+ throw new Error(result.error || "Codex OAuth exchange failed");
265
+ setCodexManualInput("");
266
+ setCodexAuthStarted(false);
267
+ setCodexAuthWaiting(false);
268
+ await refreshCodexStatus();
269
+ } catch (err) {
270
+ setError(err.message || "Codex OAuth exchange failed");
271
+ } finally {
272
+ setCodexExchanging(false);
273
+ }
274
+ };
275
+
276
+ const handleCodexDisconnect = async () => {
277
+ const result = await disconnectCodex();
278
+ if (!result.ok) {
279
+ setError(result.error || "Failed to disconnect Codex");
280
+ return;
281
+ }
282
+ setCodexAuthStarted(false);
283
+ setCodexAuthWaiting(false);
284
+ setCodexManualInput("");
285
+ await refreshCodexStatus();
286
+ };
287
+
288
+ const handleSubmit = async () => {
289
+ if (!allValid || loading) return;
290
+ setLoading(true);
291
+ setError(null);
292
+
293
+ try {
294
+ const vars = Object.entries(vals)
295
+ .filter(([key]) => key !== "MODEL_KEY")
296
+ .filter(([, v]) => v)
297
+ .map(([key, value]) => ({ key, value }));
298
+ const result = await runOnboard(vars, vals.MODEL_KEY);
299
+ if (!result.ok) throw new Error(result.error || "Onboarding failed");
300
+ localStorage.removeItem("openclaw_setup");
301
+ onComplete();
302
+ } catch (err) {
303
+ console.error("Onboard error:", err);
304
+ setError(err.message);
305
+ setLoading(false);
306
+ }
307
+ };
308
+
309
+ if (loading) {
310
+ return html`
311
+ <div
312
+ class="fixed inset-0 bg-[#0a0a0a] flex items-center justify-center z-50"
313
+ >
314
+ <div class="flex flex-col items-center gap-4">
315
+ <svg
316
+ class="animate-spin h-8 w-8 text-white"
317
+ viewBox="0 0 24 24"
318
+ fill="none"
319
+ >
320
+ <circle
321
+ class="opacity-25"
322
+ cx="12"
323
+ cy="12"
324
+ r="10"
325
+ stroke="currentColor"
326
+ stroke-width="4"
327
+ />
328
+ <path
329
+ class="opacity-75"
330
+ fill="currentColor"
331
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
332
+ />
333
+ </svg>
334
+ <h2 class="text-lg font-semibold text-white">
335
+ Initializing OpenClaw
336
+ </h2>
337
+ <p class="text-sm text-gray-500">This could take 10–15 seconds</p>
338
+ </div>
339
+ </div>
340
+ `;
341
+ }
342
+
343
+ return html`
344
+ <div class="max-w-lg w-full space-y-4">
345
+ <div class="flex items-center gap-3">
346
+ <div class="text-4xl">🦞</div>
347
+ <div>
348
+ <h1 class="text-2xl font-semibold">Welcome to OpenClaw</h1>
349
+ <p class="text-gray-500 text-sm">Let's get your agent running</p>
350
+ </div>
351
+ </div>
352
+
353
+ ${kGroups.map(
354
+ (group) => html`
355
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
356
+ <div class="flex items-center justify-between">
357
+ <div>
358
+ <h2 class="text-sm font-medium text-gray-200">
359
+ ${group.title}
360
+ </h2>
361
+ <p class="text-xs text-gray-500">${group.description}</p>
362
+ </div>
363
+ ${group.validate(vals, { hasAi })
364
+ ? html`<span
365
+ class="text-xs font-medium px-2 py-0.5 rounded-full bg-green-900/50 text-green-400"
366
+ >✓</span
367
+ >`
368
+ : group.id !== "tools"
369
+ ? html`<span
370
+ class="text-xs font-medium px-2 py-0.5 rounded-full bg-yellow-900/50 text-yellow-400"
371
+ >Required</span
372
+ >`
373
+ : null}
374
+ </div>
375
+
376
+ ${group.id === "ai" &&
377
+ html`
378
+ <div class="space-y-1">
379
+ <label class="text-xs font-medium text-gray-400">Model</label>
380
+ <select
381
+ value=${vals.MODEL_KEY || ""}
382
+ onInput=${(e) => set("MODEL_KEY", e.target.value)}
383
+ class="w-full bg-black/30 border border-border rounded-lg pl-3 pr-8 py-2 text-sm text-gray-200 outline-none focus:border-gray-500"
384
+ >
385
+ <option value="">Select a model</option>
386
+ ${modelOptions.map(
387
+ (model) => html`
388
+ <option value=${model.key}>
389
+ ${model.label || model.key}
390
+ </option>
391
+ `,
392
+ )}
393
+ </select>
394
+ <p class="text-xs text-gray-600">
395
+ ${modelsLoading
396
+ ? "Loading model catalog..."
397
+ : modelsError
398
+ ? modelsError
399
+ : ""}
400
+ </p>
401
+ ${canToggleFullCatalog &&
402
+ html`
403
+ <button
404
+ type="button"
405
+ onclick=${() => setShowAllModels((prev) => !prev)}
406
+ class="text-xs text-gray-500 hover:text-gray-300"
407
+ >
408
+ ${showAllModels
409
+ ? "Show recommended models"
410
+ : "Show full model catalog"}
411
+ </button>
412
+ `}
413
+ </div>
414
+ `}
415
+ ${group.id === "ai" &&
416
+ selectedProvider === "openai-codex" &&
417
+ html`
418
+ <div
419
+ class="bg-black/20 border border-border rounded-lg p-3 space-y-2"
420
+ >
421
+ <div class="flex items-center justify-between">
422
+ <span class="text-xs text-gray-400">Codex OAuth</span>
423
+ ${codexLoading
424
+ ? html`<span class="text-xs text-gray-500"
425
+ >Checking...</span
426
+ >`
427
+ : codexStatus.connected
428
+ ? html`<span class="text-xs text-green-400"
429
+ >Connected</span
430
+ >`
431
+ : html`<span class="text-xs text-yellow-400"
432
+ >Not connected</span
433
+ >`}
434
+ </div>
435
+ <div class="flex gap-2">
436
+ <button
437
+ type="button"
438
+ onclick=${startCodexAuth}
439
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ${codexStatus.connected
440
+ ? "border border-border text-gray-300 hover:border-gray-500"
441
+ : "bg-white text-black hover:opacity-85"}"
442
+ >
443
+ ${codexStatus.connected
444
+ ? "Reconnect Codex"
445
+ : "Connect Codex OAuth"}
446
+ </button>
447
+ ${codexStatus.connected &&
448
+ html`
449
+ <button
450
+ type="button"
451
+ onclick=${handleCodexDisconnect}
452
+ class="text-xs font-medium px-3 py-1.5 rounded-lg border border-border text-gray-300 hover:border-gray-500"
453
+ >
454
+ Disconnect
455
+ </button>
456
+ `}
457
+ </div>
458
+ ${!codexStatus.connected &&
459
+ codexAuthStarted &&
460
+ html`
461
+ <div class="space-y-1 pt-1">
462
+ <p class="text-xs text-gray-500">
463
+ ${codexAuthWaiting
464
+ ? "Complete login in the popup, then paste the full redirect URL from the address bar (starts with "
465
+ : "Paste the full redirect URL from the address bar (starts with "}
466
+ <code class="text-xs bg-black/30 px-1 rounded"
467
+ >http://localhost:1455/auth/callback</code
468
+ >)
469
+ ${codexAuthWaiting
470
+ ? " to finish setup."
471
+ : " to finish setup."}
472
+ </p>
473
+ <input
474
+ type="text"
475
+ value=${codexManualInput}
476
+ onInput=${(e) => setCodexManualInput(e.target.value)}
477
+ placeholder="http://localhost:1455/auth/callback?code=...&state=..."
478
+ class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-xs text-gray-200 outline-none focus:border-gray-500"
479
+ />
480
+ <button
481
+ type="button"
482
+ onclick=${completeCodexAuth}
483
+ disabled=${!codexManualInput.trim() || codexExchanging}
484
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ${!codexManualInput.trim() ||
485
+ codexExchanging
486
+ ? "bg-gray-700 text-gray-400 cursor-not-allowed"
487
+ : "bg-white text-black hover:opacity-85"}"
488
+ >
489
+ ${codexExchanging
490
+ ? "Completing..."
491
+ : "Complete Codex OAuth"}
492
+ </button>
493
+ </div>
494
+ `}
495
+ </div>
496
+ `}
497
+ ${(group.id === "ai"
498
+ ? group.fields.filter((field) =>
499
+ visibleAiFieldKeys.has(field.key),
500
+ )
501
+ : group.fields
502
+ ).map(
503
+ (field) => html`
504
+ <div class="space-y-1">
505
+ <label class="text-xs font-medium text-gray-400"
506
+ >${field.label}</label
507
+ >
508
+ <input
509
+ type=${field.isText ? "text" : "password"}
510
+ placeholder=${field.placeholder || ""}
511
+ value=${vals[field.key] || ""}
512
+ onInput=${(e) => set(field.key, e.target.value)}
513
+ class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm text-gray-200 outline-none focus:border-gray-500 font-mono"
514
+ />
515
+ <p class="text-xs text-gray-600">${field.hint}</p>
516
+ </div>
517
+ `,
518
+ )}
519
+ </div>
520
+ `,
521
+ )}
522
+ ${error
523
+ ? html`<div
524
+ class="bg-red-900/30 border border-red-800 rounded-xl p-3 text-red-300 text-sm"
525
+ >
526
+ ${error}
527
+ </div>`
528
+ : null}
529
+
530
+ <button
531
+ onclick=${handleSubmit}
532
+ disabled=${!allValid}
533
+ class="w-full text-sm font-medium px-4 py-3 rounded-xl transition-all ${allValid
534
+ ? "bg-white text-black hover:opacity-85"
535
+ : "bg-gray-800 text-gray-500 cursor-not-allowed"}"
536
+ >
537
+ Complete Setup
538
+ </button>
539
+ </div>
540
+ `;
541
+ };
@@ -0,0 +1,29 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'https://esm.sh/preact/hooks';
2
+
3
+ export const usePolling = (fetcher, interval, { enabled = true } = {}) => {
4
+ const [data, setData] = useState(null);
5
+ const [error, setError] = useState(null);
6
+ const fetcherRef = useRef(fetcher);
7
+ fetcherRef.current = fetcher;
8
+
9
+ const refresh = useCallback(async () => {
10
+ try {
11
+ const result = await fetcherRef.current();
12
+ setData(result);
13
+ setError(null);
14
+ return result;
15
+ } catch (err) {
16
+ setError(err);
17
+ return null;
18
+ }
19
+ }, []);
20
+
21
+ useEffect(() => {
22
+ if (!enabled) return;
23
+ refresh();
24
+ const id = setInterval(refresh, interval);
25
+ return () => clearInterval(id);
26
+ }, [enabled, interval, refresh]);
27
+
28
+ return { data, error, refresh };
29
+ };