@chrysb/alphaclaw 0.1.19 → 0.1.21

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
@@ -106,20 +106,42 @@ try {
106
106
  }
107
107
 
108
108
  // ---------------------------------------------------------------------------
109
- // 4. Seed .env from template if missing; load into process.env
109
+ // 4. Ensure shared ~/data/.env exists (seed from template if missing)
110
110
  // ---------------------------------------------------------------------------
111
111
 
112
112
  const envFilePath = path.join(rootDir, ".env");
113
+ const sharedDataDir = path.join(os.homedir(), "data");
114
+ const sharedEnvFilePath = path.join(sharedDataDir, ".env");
113
115
  const setupDir = path.join(__dirname, "..", "lib", "setup");
116
+ const templatePath = path.join(setupDir, "env.template");
114
117
 
115
- if (!fs.existsSync(envFilePath)) {
116
- const templatePath = path.join(setupDir, "env.template");
117
- if (fs.existsSync(templatePath)) {
118
- fs.copyFileSync(templatePath, envFilePath);
119
- console.log("[alphaclaw] Created .env from template");
118
+ try {
119
+ if (!fs.existsSync(sharedEnvFilePath) && fs.existsSync(templatePath)) {
120
+ fs.mkdirSync(sharedDataDir, { recursive: true });
121
+ fs.copyFileSync(templatePath, sharedEnvFilePath);
122
+ console.log(`[alphaclaw] Created shared env at ${sharedEnvFilePath}`);
120
123
  }
124
+ } catch (e) {
125
+ console.log(`[alphaclaw] Shared .env setup skipped: ${e.message}`);
121
126
  }
122
127
 
128
+ // ---------------------------------------------------------------------------
129
+ // 5. Symlink <root>/.env -> ~/data/.env when available
130
+ // ---------------------------------------------------------------------------
131
+
132
+ try {
133
+ if (!fs.existsSync(envFilePath) && fs.existsSync(sharedEnvFilePath)) {
134
+ fs.symlinkSync(sharedEnvFilePath, envFilePath);
135
+ console.log(`[alphaclaw] Symlinked ${envFilePath} -> ${sharedEnvFilePath}`);
136
+ }
137
+ } catch (e) {
138
+ console.log(`[alphaclaw] .env symlink skipped: ${e.message}`);
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // 6. Load .env into process.env
143
+ // ---------------------------------------------------------------------------
144
+
123
145
  if (fs.existsSync(envFilePath)) {
124
146
  const content = fs.readFileSync(envFilePath, "utf8");
125
147
  for (const line of content.split("\n")) {
@@ -135,14 +157,14 @@ if (fs.existsSync(envFilePath)) {
135
157
  }
136
158
 
137
159
  // ---------------------------------------------------------------------------
138
- // 5. Set OPENCLAW_HOME globally so all child processes inherit it
160
+ // 7. Set OPENCLAW_HOME globally so all child processes inherit it
139
161
  // ---------------------------------------------------------------------------
140
162
 
141
163
  process.env.OPENCLAW_HOME = rootDir;
142
164
  process.env.OPENCLAW_CONFIG_PATH = path.join(openclawDir, "openclaw.json");
143
165
 
144
166
  // ---------------------------------------------------------------------------
145
- // 6. Install gog (Google Workspace CLI) if not present
167
+ // 8. Install gog (Google Workspace CLI) if not present
146
168
  // ---------------------------------------------------------------------------
147
169
 
148
170
  process.env.XDG_CONFIG_HOME = openclawDir;
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -28.5 256 256" role="img" aria-labelledby="title">
2
+ <title>Discord</title>
3
+ <path
4
+ d="M216.856339 16.5966031C200.285002 8.84328665 182.566144 3.2084988 164.041564 0C161.766523 4.11318106 159.108624 9.64549908 157.276099 14.0464379C137.583995 11.0849896 118.072967 11.0849896 98.7430163 14.0464379C96.9108417 9.64549908 94.1925838 4.11318106 91.8971895 0C73.3526068 3.2084988 55.6133949 8.86399117 39.0420583 16.6376612C5.61752293 67.146514 -3.4433191 116.400813 1.08711069 164.955721C23.2560196 181.510915 44.7403634 191.567697 65.8621325 198.148576C71.0772151 190.971126 75.7283628 183.341335 79.7352139 175.300261C72.104019 172.400575 64.7949724 168.822202 57.8887866 164.667963C59.7209612 163.310589 61.5131304 161.891452 63.2445898 160.431257C105.36741 180.133187 151.134928 180.133187 192.754523 160.431257C194.506336 161.891452 196.298154 163.310589 198.110326 164.667963C191.183787 168.842556 183.854737 172.420929 176.223542 175.320965C180.230393 183.341335 184.861538 190.991831 190.096624 198.16893C211.238746 191.588051 232.743023 181.531619 254.911949 164.955721C260.227747 108.668201 245.831087 59.8662432 216.856339 16.5966031ZM85.4738752 135.09489C72.8290281 135.09489 62.4592217 123.290155 62.4592217 108.914901C62.4592217 94.5396472 72.607595 82.7145587 85.4738752 82.7145587C98.3405064 82.7145587 108.709962 94.5189427 108.488529 108.914901C108.508531 123.290155 98.3405064 135.09489 85.4738752 135.09489ZM170.525237 135.09489C157.88039 135.09489 147.510584 123.290155 147.510584 108.914901C147.510584 94.5396472 157.658606 82.7145587 170.525237 82.7145587C183.391518 82.7145587 193.761324 94.5189427 193.539891 108.914901C193.539891 123.290155 183.391518 135.09489 170.525237 135.09489Z"
5
+ fill="#5865F2"
6
+ />
7
+ </svg>
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240" role="img" aria-labelledby="title">
2
+ <title>Telegram</title>
3
+ <defs>
4
+ <linearGradient id="telegram-gradient" x1="120" y1="240" x2="120" y2="0" gradientUnits="userSpaceOnUse">
5
+ <stop offset="0" stop-color="#1d93d2"/>
6
+ <stop offset="1" stop-color="#38b0e3"/>
7
+ </linearGradient>
8
+ </defs>
9
+ <circle cx="120" cy="120" r="120" fill="url(#telegram-gradient)"/>
10
+ <path d="M81.229 128.772l14.237 39.406s1.78 3.687 3.686 3.687 30.255-29.492 30.255-29.492l31.525-60.89L81.737 118.6z" fill="#c8daea"/>
11
+ <path d="M100.106 138.878l-2.733 29.046s-1.144 8.9 7.754 0 17.415-15.763 17.415-15.763" fill="#a9c6d8"/>
12
+ <path d="M81.486 130.178L52.2 120.636s-3.5-1.42-2.373-4.64c.232-.664.7-1.229 2.1-2.2 6.489-4.523 120.106-45.36 120.106-45.36s3.208-1.081 5.1-.362a2.766 2.766 0 0 1 1.885 2.055 9.357 9.357 0 0 1 .254 2.585c-.009.752-.1 1.449-.169 2.542-.692 11.165-21.4 94.493-21.4 94.493s-1.239 4.876-5.678 5.043a8.13 8.13 0 0 1-5.925-2.292c-8.711-7.493-38.819-27.727-45.472-32.177a1.27 1.27 0 0 1-.546-.9c-.093-.469.417-1.05.417-1.05s52.426-46.6 53.821-51.492c.108-.379-.3-.566-.848-.4-3.482 1.281-63.844 39.4-70.506 43.607a3.21 3.21 0 0 1-1.48.09z" fill="#fff"/>
13
+ </svg>
@@ -19,8 +19,10 @@
19
19
  /* ── Sidebar ───────────────────────────────────── */
20
20
 
21
21
  .app-sidebar {
22
- background: var(--bg-sidebar);
23
- border-right: 1px solid var(--border);
22
+ background:
23
+ linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%),
24
+ var(--bg-sidebar);
25
+ border-right: 1px solid var(--border-strong);
24
26
  overflow-y: auto;
25
27
  display: flex;
26
28
  flex-direction: column;
@@ -108,12 +110,58 @@
108
110
  .sidebar-update-btn:hover { background: rgba(227, 179, 65, 0.14); border-color: rgba(227, 179, 65, 0.35); }
109
111
  .sidebar-update-btn:disabled { opacity: 0.5; cursor: not-allowed; }
110
112
 
113
+ /* ── Brand dropdown menu ───────────────────────── */
114
+
115
+ .brand-menu {
116
+ position: relative;
117
+ margin-left: auto;
118
+ }
119
+
120
+ .brand-menu-trigger {
121
+ display: flex;
122
+ align-items: center;
123
+ justify-content: center;
124
+ width: 24px;
125
+ height: 24px;
126
+ border: none;
127
+ border-radius: 6px;
128
+ background: transparent;
129
+ color: var(--text-dim);
130
+ cursor: pointer;
131
+ transition: background 0.1s, color 0.1s;
132
+ }
133
+ .brand-menu-trigger:hover { background: var(--bg-hover); color: var(--text-muted); }
134
+
135
+ .brand-dropdown {
136
+ position: absolute;
137
+ top: calc(100% + 4px);
138
+ right: 0;
139
+ min-width: 120px;
140
+ background: var(--bg-sidebar);
141
+ border: 1px solid var(--border-strong);
142
+ border-radius: 8px;
143
+ padding: 4px;
144
+ z-index: 50;
145
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
146
+ }
147
+
148
+ .brand-dropdown a {
149
+ display: block;
150
+ padding: 6px 10px;
151
+ font-size: 12px;
152
+ color: var(--text-muted);
153
+ text-decoration: none;
154
+ border-radius: 5px;
155
+ transition: background 0.1s, color 0.1s;
156
+ }
157
+ .brand-dropdown a:hover { background: var(--bg-hover); color: var(--text); }
158
+
111
159
  /* ── Statusbar ─────────────────────────────────── */
112
160
 
113
161
  .app-statusbar {
114
162
  grid-column: 1 / -1;
115
163
  background: var(--bg-sidebar);
116
- border-top: 1px solid var(--border);
164
+ border-top: 1px solid var(--border-strong);
117
165
  display: flex;
118
166
  align-items: center;
119
167
  justify-content: space-between;
@@ -1,16 +1,21 @@
1
1
  :root {
2
- --bg: #0a0e14;
3
- --bg-sidebar: #0d1117;
4
- --bg-content: #0a0e14;
2
+ --bg: #0d121b;
3
+ --bg-sidebar: #111826;
4
+ --bg-content: #0f1521;
5
5
  --bg-hover: rgba(99, 235, 255, 0.05);
6
6
  --bg-active: rgba(99, 235, 255, 0.08);
7
7
  --border: rgba(255, 255, 255, 0.06);
8
+ --border-strong: rgba(255, 255, 255, 0.11);
8
9
  --text: #c9d1d9;
9
10
  --text-muted: #6e7681;
10
11
  --text-dim: #383d47;
11
12
  --accent: #63ebff;
12
13
  --accent-dim: rgba(99, 235, 255, 0.4);
13
14
  --accent-link: rgba(99, 235, 255, 0.6);
15
+ --panel-bg-contrast: rgba(255, 255, 255, 0.028);
16
+ --panel-border-contrast: rgba(255, 255, 255, 0.11);
17
+ --field-bg-contrast: rgba(0, 0, 0, 0.3);
18
+ --field-border-contrast: rgba(255, 255, 255, 0.13);
14
19
  }
15
20
 
16
21
  html, body { height: 100%; }
@@ -29,13 +34,37 @@ body::before {
29
34
  position: fixed;
30
35
  inset: 0;
31
36
  background-image:
32
- linear-gradient(rgba(255, 255, 255, 0.015) 1px, transparent 1px),
33
- linear-gradient(90deg, rgba(255, 255, 255, 0.015) 1px, transparent 1px);
37
+ linear-gradient(rgba(255, 255, 255, 0.024) 1px, transparent 1px),
38
+ linear-gradient(90deg, rgba(255, 255, 255, 0.024) 1px, transparent 1px);
34
39
  background-size: 48px 48px;
35
40
  pointer-events: none;
36
41
  z-index: 0;
37
42
  }
38
43
 
44
+ /* Unified panel treatment across tabs/pages. */
45
+ .bg-surface {
46
+ background: var(--panel-bg-contrast) !important;
47
+ border-color: var(--panel-border-contrast) !important;
48
+ }
49
+
50
+ .border-border {
51
+ border-color: var(--panel-border-contrast) !important;
52
+ }
53
+
54
+ /* Universal field contrast treatment (all tabs/pages). */
55
+ input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
56
+ select,
57
+ textarea {
58
+ background: var(--field-bg-contrast);
59
+ border-color: var(--field-border-contrast);
60
+ }
61
+
62
+ input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):focus,
63
+ select:focus,
64
+ textarea:focus {
65
+ border-color: rgba(255, 255, 255, 0.28);
66
+ }
67
+
39
68
  ::placeholder { color: var(--text-dim) !important; opacity: 1 !important; }
40
69
  ::-webkit-input-placeholder { color: var(--text-dim) !important; }
41
70
  ::-moz-placeholder { color: var(--text-dim) !important; }
@@ -1,5 +1,5 @@
1
1
  import { h, render } from "https://esm.sh/preact";
2
- import { useState, useEffect } from "https://esm.sh/preact/hooks";
2
+ import { useState, useEffect, useRef, useCallback } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import {
5
5
  fetchStatus,
@@ -10,6 +10,8 @@ import {
10
10
  approveDevice,
11
11
  rejectDevice,
12
12
  fetchOnboardStatus,
13
+ fetchAuthStatus,
14
+ logout,
13
15
  fetchDashboardUrl,
14
16
  updateSyncCron,
15
17
  fetchAlphaclawVersion,
@@ -27,7 +29,6 @@ import { Envars } from "./components/envars.js";
27
29
  import { ToastContainer, showToast } from "./components/toast.js";
28
30
  import { ChevronDownIcon } from "./components/icons.js";
29
31
  const html = htm.bind(h);
30
- const kUiTabStorageKey = "alphaclaw_ui_tab";
31
32
  const kUiTabs = ["general", "models", "envars"];
32
33
  const kDefaultUiTab = "general";
33
34
 
@@ -278,29 +279,42 @@ const GeneralTab = ({ onSwitchTab, isActive }) => {
278
279
  function App() {
279
280
  const [onboarded, setOnboarded] = useState(null);
280
281
  const [tab, setTab] = useState(() => {
281
- try {
282
- const savedTab = localStorage.getItem(kUiTabStorageKey);
283
- return kUiTabs.includes(savedTab) ? savedTab : kDefaultUiTab;
284
- } catch {
285
- return kDefaultUiTab;
286
- }
282
+ const hash = window.location.hash.replace("#", "");
283
+ return kUiTabs.includes(hash) ? hash : kDefaultUiTab;
287
284
  });
288
285
  const [acVersion, setAcVersion] = useState(null);
289
286
  const [acLatest, setAcLatest] = useState(null);
290
287
  const [acHasUpdate, setAcHasUpdate] = useState(false);
291
288
  const [acUpdating, setAcUpdating] = useState(false);
292
289
  const [acDismissed, setAcDismissed] = useState(false);
290
+ const [authEnabled, setAuthEnabled] = useState(false);
291
+ const [menuOpen, setMenuOpen] = useState(false);
292
+ const menuRef = useRef(null);
293
+
294
+ const closeMenu = useCallback((e) => {
295
+ if (menuRef.current && !menuRef.current.contains(e.target)) {
296
+ setMenuOpen(false);
297
+ }
298
+ }, []);
299
+
300
+ useEffect(() => {
301
+ if (menuOpen) {
302
+ document.addEventListener("click", closeMenu, true);
303
+ return () => document.removeEventListener("click", closeMenu, true);
304
+ }
305
+ }, [menuOpen, closeMenu]);
293
306
 
294
307
  useEffect(() => {
295
308
  fetchOnboardStatus()
296
309
  .then((data) => setOnboarded(data.onboarded))
297
310
  .catch(() => setOnboarded(false));
311
+ fetchAuthStatus()
312
+ .then((data) => setAuthEnabled(!!data.authEnabled))
313
+ .catch(() => {});
298
314
  }, []);
299
315
 
300
316
  useEffect(() => {
301
- try {
302
- localStorage.setItem(kUiTabStorageKey, tab);
303
- } catch {}
317
+ history.replaceState(null, "", `#${tab}`);
304
318
  }, [tab]);
305
319
 
306
320
  useEffect(() => {
@@ -397,6 +411,34 @@ function App() {
397
411
  <div class="sidebar-brand">
398
412
  <img src="./img/logo.svg" alt="" width="20" height="20" />
399
413
  <span><span style="color: var(--accent)">alpha</span>claw</span>
414
+ ${authEnabled && html`
415
+ <div class="brand-menu" ref=${menuRef}>
416
+ <button
417
+ class="brand-menu-trigger"
418
+ onclick=${() => setMenuOpen((o) => !o)}
419
+ aria-label="Menu"
420
+ >
421
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
422
+ <circle cx="8" cy="3" r="1.5" />
423
+ <circle cx="8" cy="8" r="1.5" />
424
+ <circle cx="8" cy="13" r="1.5" />
425
+ </svg>
426
+ </button>
427
+ ${menuOpen && html`
428
+ <div class="brand-dropdown">
429
+ <a
430
+ href="#"
431
+ onclick=${async (e) => {
432
+ e.preventDefault();
433
+ setMenuOpen(false);
434
+ await logout();
435
+ window.location.href = "/login.html";
436
+ }}
437
+ >Log out</a>
438
+ </div>
439
+ `}
440
+ </div>
441
+ `}
400
442
  </div>
401
443
  <div class="sidebar-label">Setup</div>
402
444
  <nav class="sidebar-nav">
@@ -429,7 +471,10 @@ function App() {
429
471
  <div class="app-content">
430
472
  <div class="max-w-2xl w-full mx-auto space-y-4">
431
473
  <div style=${{ display: tab === "general" ? "" : "none" }}>
432
- <${GeneralTab} onSwitchTab=${setTab} isActive=${tab === "general"} />
474
+ <${GeneralTab}
475
+ onSwitchTab=${setTab}
476
+ isActive=${tab === "general"}
477
+ />
433
478
  </div>
434
479
  <div style=${{ display: tab === "models" ? "" : "none" }}>
435
480
  <${Models} />
@@ -4,6 +4,10 @@ import { Badge } from './badge.js';
4
4
  const html = htm.bind(h);
5
5
 
6
6
  const ALL_CHANNELS = ['telegram', 'discord'];
7
+ const kChannelMeta = {
8
+ telegram: { label: 'Telegram', iconSrc: '/assets/icons/telegram.svg' },
9
+ discord: { label: 'Discord', iconSrc: '/assets/icons/discord.svg' },
10
+ };
7
11
 
8
12
  export function Channels({ channels, onSwitchTab }) {
9
13
  return html`
@@ -12,6 +16,7 @@ export function Channels({ channels, onSwitchTab }) {
12
16
  <div class="space-y-2">
13
17
  ${channels ? ALL_CHANNELS.map(ch => {
14
18
  const info = channels[ch];
19
+ const channelMeta = kChannelMeta[ch] || { label: ch.charAt(0).toUpperCase() + ch.slice(1), iconSrc: '' };
15
20
  let badge;
16
21
  if (!info) {
17
22
  badge = html`<a
@@ -25,7 +30,12 @@ export function Channels({ channels, onSwitchTab }) {
25
30
  badge = html`<${Badge} tone="warning">Awaiting pairing</${Badge}>`;
26
31
  }
27
32
  return html`<div class="flex justify-between items-center py-1.5">
28
- <span class="font-medium text-sm">${ch.charAt(0).toUpperCase() + ch.slice(1)}</span>
33
+ <span class="font-medium text-sm flex items-center gap-2">
34
+ ${channelMeta.iconSrc
35
+ ? html`<img src=${channelMeta.iconSrc} alt="" class="w-4 h-4 rounded-sm" aria-hidden="true" />`
36
+ : null}
37
+ ${channelMeta.label}
38
+ </span>
29
39
  ${badge}
30
40
  </div>`;
31
41
  }) : html`<div class="text-gray-500 text-sm text-center py-2">Loading...</div>`}
@@ -1,5 +1,5 @@
1
1
  import { h } from "https://esm.sh/preact";
2
- import { useState, useEffect, useCallback } from "https://esm.sh/preact/hooks";
2
+ import { useState, useEffect, useCallback, useRef } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
4
  import { fetchEnvVars, saveEnvVars, restartGateway } from "../lib/api.js";
5
5
  import { showToast } from "./toast.js";
@@ -14,6 +14,16 @@ const kGroupLabels = {
14
14
  };
15
15
 
16
16
  const kGroupOrder = ["github", "channels", "tools", "custom"];
17
+ const normalizeEnvVarKey = (raw) => raw.trim().toUpperCase().replace(/[^A-Z0-9_]/g, "_");
18
+ const getVarsSignature = (items) =>
19
+ JSON.stringify(
20
+ (items || [])
21
+ .map((v) => ({
22
+ key: String(v?.key || ""),
23
+ value: String(v?.value || ""),
24
+ }))
25
+ .sort((a, b) => a.key.localeCompare(b.key)),
26
+ );
17
27
 
18
28
  const kHintByKey = {
19
29
  ANTHROPIC_API_KEY: html`from <a href="https://console.anthropic.com" target="_blank" class="hover:underline" style="color: var(--accent-link)">console.anthropic.com</a>`,
@@ -72,17 +82,21 @@ const EnvRow = ({ envVar, onChange, onDelete, disabled }) => {
72
82
 
73
83
  export const Envars = () => {
74
84
  const [vars, setVars] = useState([]);
85
+ const [reservedKeys, setReservedKeys] = useState(() => new Set());
75
86
  const [dirty, setDirty] = useState(false);
76
87
  const [saving, setSaving] = useState(false);
77
88
  const [restartingGateway, setRestartingGateway] = useState(false);
78
89
  const [restartRequired, setRestartRequired] = useState(false);
79
90
  const [newKey, setNewKey] = useState("");
91
+ const baselineSignatureRef = useRef("[]");
80
92
 
81
93
  const load = useCallback(async () => {
82
94
  try {
83
95
  const data = await fetchEnvVars();
84
- setVars(data.vars || []);
85
- setDirty(false);
96
+ const nextVars = data.vars || [];
97
+ baselineSignatureRef.current = getVarsSignature(nextVars);
98
+ setVars(nextVars);
99
+ setReservedKeys(new Set(data.reservedKeys || []));
86
100
  setRestartRequired(!!data.restartRequired);
87
101
  } catch (err) {
88
102
  console.error("Failed to load env vars:", err);
@@ -93,14 +107,16 @@ export const Envars = () => {
93
107
  load();
94
108
  }, [load]);
95
109
 
110
+ useEffect(() => {
111
+ setDirty(getVarsSignature(vars) !== baselineSignatureRef.current);
112
+ }, [vars]);
113
+
96
114
  const handleChange = (key, value) => {
97
115
  setVars((prev) => prev.map((v) => (v.key === key ? { ...v, value } : v)));
98
- setDirty(true);
99
116
  };
100
117
 
101
118
  const handleDelete = (key) => {
102
119
  setVars((prev) => prev.filter((v) => v.key !== key));
103
- setDirty(true);
104
120
  };
105
121
 
106
122
  const handleSave = async () => {
@@ -119,6 +135,7 @@ export const Envars = () => {
119
135
  : "Environment variables saved",
120
136
  "success",
121
137
  );
138
+ baselineSignatureRef.current = getVarsSignature(vars);
122
139
  setDirty(false);
123
140
  } catch (err) {
124
141
  showToast("Failed to save: " + err.message, "error");
@@ -163,11 +180,16 @@ export const Envars = () => {
163
180
 
164
181
  const addVars = (pairs) => {
165
182
  let added = 0;
183
+ const blocked = [];
166
184
  setVars((prev) => {
167
185
  const next = [...prev];
168
186
  for (const { key: rawKey, value } of pairs) {
169
- const key = rawKey.toUpperCase().replace(/[^A-Z0-9_]/g, "_");
187
+ const key = normalizeEnvVarKey(rawKey);
170
188
  if (!key) continue;
189
+ if (reservedKeys.has(key)) {
190
+ blocked.push(key);
191
+ continue;
192
+ }
171
193
  const existing = next.find((v) => v.key === key);
172
194
  if (existing) {
173
195
  existing.value = value;
@@ -186,8 +208,7 @@ export const Envars = () => {
186
208
  }
187
209
  return next;
188
210
  });
189
- if (added) setDirty(true);
190
- return added;
211
+ return { added, blocked };
191
212
  };
192
213
 
193
214
  const handlePaste = (e, fallbackField) => {
@@ -195,10 +216,19 @@ export const Envars = () => {
195
216
  const pairs = parsePaste(text);
196
217
  if (pairs.length > 1) {
197
218
  e.preventDefault();
198
- const added = addVars(pairs);
219
+ const { added, blocked } = addVars(pairs);
199
220
  setNewKey("");
200
221
  setNewVal("");
201
- showToast(`Added ${added} variable${added !== 1 ? "s" : ""}`, "success");
222
+ if (blocked.length) {
223
+ const uniqueBlocked = Array.from(new Set(blocked));
224
+ showToast(
225
+ `Reserved vars can't be added: ${uniqueBlocked.join(", ")}`,
226
+ "error",
227
+ );
228
+ }
229
+ if (added) {
230
+ showToast(`Added ${added} variable${added !== 1 ? "s" : ""}`, "success");
231
+ }
202
232
  return;
203
233
  }
204
234
  if (pairs.length === 1) {
@@ -230,11 +260,12 @@ export const Envars = () => {
230
260
  };
231
261
 
232
262
  const handleAddVar = () => {
233
- const key = newKey
234
- .trim()
235
- .toUpperCase()
236
- .replace(/[^A-Z0-9_]/g, "_");
263
+ const key = normalizeEnvVarKey(newKey);
237
264
  if (!key) return;
265
+ if (reservedKeys.has(key)) {
266
+ showToast(`Reserved var can't be added: ${key}`, "error");
267
+ return;
268
+ }
238
269
  addVars([{ key, value: newVal }]);
239
270
  setNewKey("");
240
271
  setNewVal("");
@@ -13,6 +13,7 @@ import {
13
13
  } from "../lib/api.js";
14
14
  import { showToast } from "./toast.js";
15
15
  import { Badge } from "./badge.js";
16
+ import { SecretInput } from "./secret-input.js";
16
17
  import {
17
18
  getModelProvider,
18
19
  getAuthProviderFromModelProvider,
@@ -274,12 +275,12 @@ export const Models = () => {
274
275
  const renderCredentialField = (field) => html`
275
276
  <div class="space-y-1">
276
277
  <label class="text-xs font-medium text-gray-400">${field.label}</label>
277
- <input
278
- type="password"
279
- placeholder=${field.placeholder || ""}
278
+ <${SecretInput}
280
279
  value=${getKeyVal(envVars, field.key)}
281
280
  onInput=${(e) => setEnvValue(field.key, e.target.value)}
282
- 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"
281
+ placeholder=${field.placeholder || ""}
282
+ isSecret=${!field.isText}
283
+ inputClass="flex-1 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"
283
284
  />
284
285
  <p class="text-xs text-gray-600">${field.hint}</p>
285
286
  </div>
@@ -0,0 +1,12 @@
1
+ export const getPreferredPairingChannel = (vals = {}) => {
2
+ if (vals.TELEGRAM_BOT_TOKEN) return "telegram";
3
+ if (vals.DISCORD_BOT_TOKEN) return "discord";
4
+ return "";
5
+ };
6
+
7
+ export const isChannelPaired = (channels = {}, channel = "") => {
8
+ if (!channel) return false;
9
+ const info = channels?.[channel];
10
+ if (!info) return false;
11
+ return info.status === "paired" && Number(info.paired || 0) > 0;
12
+ };
@@ -4,6 +4,20 @@ import { kAllAiAuthFields } from "../../lib/model-config.js";
4
4
 
5
5
  const html = htm.bind(h);
6
6
 
7
+ export const normalizeGithubRepoInput = (repoInput) =>
8
+ String(repoInput || "")
9
+ .trim()
10
+ .replace(/^git@github\.com:/, "")
11
+ .replace(/^https:\/\/github\.com\//, "")
12
+ .replace(/\.git$/, "");
13
+
14
+ export const isValidGithubRepoInput = (repoInput) => {
15
+ const cleaned = normalizeGithubRepoInput(repoInput);
16
+ if (!cleaned) return false;
17
+ const parts = cleaned.split("/").filter(Boolean);
18
+ return parts.length === 2 && !parts.some((part) => /\s/.test(part));
19
+ };
20
+
7
21
  export const kWelcomeGroups = [
8
22
  {
9
23
  id: "ai",
@@ -39,7 +53,10 @@ export const kWelcomeGroups = [
39
53
  placeholder: "ghp_...",
40
54
  },
41
55
  ],
42
- validate: (vals) => !!(vals.GITHUB_TOKEN && vals.GITHUB_WORKSPACE_REPO),
56
+ validate: (vals) =>
57
+ !!(
58
+ vals.GITHUB_TOKEN && isValidGithubRepoInput(vals.GITHUB_WORKSPACE_REPO)
59
+ ),
43
60
  },
44
61
  {
45
62
  id: "channels",