create-openclaw-bot 5.10.0 → 5.11.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.
- package/README.md +27 -6
- package/README.vi.md +27 -6
- package/dist/server/local-server.js +276 -329
- package/dist/web/app.js +50 -16
- package/dist/web/styles.css +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
### One **Web UI** to scaffold, deploy & run free multi-bot AI assistants — zero terminal required
|
|
6
6
|
|
|
7
|
-
*Run one command → open the dashboard → your bot is live. Windows · macOS · Linux · VPS
|
|
7
|
+
*Run one command → open the dashboard → your bot is live. Windows · macOS · Linux · VPS — Docker-powered, auto-installed for you.*
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
|
-
<a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.
|
|
10
|
+
<a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.11.0-0EA5E9?style=for-the-badge" alt="Version 5.11.0" /></a>
|
|
11
11
|
<a href="https://github.com/tuanminhhole/openclaw-setup?tab=MIT-1-ov-file"><img src="https://img.shields.io/badge/LICENSE-MIT-success?style=for-the-badge" alt="MIT License" /></a>
|
|
12
12
|
<a href="https://www.npmjs.com/package/create-openclaw-bot"><img src="https://img.shields.io/npm/v/create-openclaw-bot?style=for-the-badge&label=CLI&color=2563EB&logo=npm&logoColor=white" alt="NPM Version" /></a>
|
|
13
13
|
<a href="https://github.com/tuanminhhole/openclaw-setup/stargazers"><img src="https://img.shields.io/github/stars/tuanminhhole/openclaw-setup?style=for-the-badge&color=eab308&logo=github&logoColor=white" alt="GitHub Stars" /></a>
|
|
@@ -27,7 +27,26 @@
|
|
|
27
27
|
|
|
28
28
|
---
|
|
29
29
|
|
|
30
|
-
## 🆕 What's New in v5.
|
|
30
|
+
## 🆕 What's New in v5.11.0
|
|
31
|
+
|
|
32
|
+
- 🚀 **Facebook Messenger, 1-click**: the `fb-messenger` plugin is now **public on ClawHub** and installs straight from the dashboard — create a Messenger bot, open **Bot → Plugins**, and hit **Install** on the `openclaw-fb-messenger` card. Webhook + Graph API, auto User→Page token, HMAC verify.
|
|
33
|
+
- 🐳 **Docker-only, rock-solid**: dropped the native (non-Docker) install path to focus on the Docker flow that runs flawlessly and stably across Windows / macOS / Linux / VPS, with cross-OS Docker auto-install.
|
|
34
|
+
- 🖥️ **Chrome-debug on headless VPS**: the browser-automation Chrome-debug relay now works on a headless VPS (bridge-IP-scoped, auto-opens ufw); the bot file editor also saves non-`.md` text files.
|
|
35
|
+
|
|
36
|
+
<details>
|
|
37
|
+
<summary><b>Previous: What's new in v5.10.1</b></summary>
|
|
38
|
+
|
|
39
|
+
- 🔧 **Docker fixes**: plugin updates keep the zalo-mod dashboard port, and granted disk/folder mounts (including whole Windows drives like `D:\`) now survive a rebuild instead of being dropped.
|
|
40
|
+
- 🔄 **Native auto-restart (process supervision)**: native installs now register the gateway + 9router as OS services (macOS launchd, Linux systemd, Windows detached) that restart on crash/reboot — mirroring Docker's `restart: always`. Best-effort, falls back to a plain detached process.
|
|
41
|
+
- 🔐 **9router first-install sync — fixed**: no longer permanently disables "Require login". The generated `sync.js` logs in with 9router's default password `123456`, builds the `smart-route` combo from active providers **once**, then stops. Require login stays ON (change the password later); `/v1` model calls are unaffected.
|
|
42
|
+
- 📁 **Native workspace path — fixed**: agent `workspace` is now relative, so persona/memory/skills resolve correctly on native installs (previously a container-absolute path pointing nowhere on the host).
|
|
43
|
+
- 🧩 **Config `meta` no longer clobbered**: the generator stops seeding `meta`, so OpenClaw owns it — prevents config parse failures from a version-range `lastTouchedVersion`.
|
|
44
|
+
- 💅 **Dashboard UI polish**: Bot/Setup hero shortcuts, plugin version badges, cleaner feature-toggle layout, responsive/mobile fixes, and no more duplicate page title on the dashboard tab.
|
|
45
|
+
|
|
46
|
+
</details>
|
|
47
|
+
|
|
48
|
+
<details>
|
|
49
|
+
<summary><b>Previous: What's new in v5.9.0</b></summary>
|
|
31
50
|
|
|
32
51
|
- 🧠 **TencentDB Agent Memory — 1-click install**: New memory plugin in the UI. A 4-tier (L0–L3) layered-memory + context-compression engine that keeps long sessions coherent and **cuts token usage by up to ~61%**. Fully local (SQLite), no API key, Docker-ready.
|
|
33
52
|
- ⚡ **Token-lean defaults for every new bot**: Ships with context pruning (cache-TTL) + `safeguard` compaction — **cheaper & sharper long conversations** with zero tuning.
|
|
@@ -36,6 +55,8 @@
|
|
|
36
55
|
- 🐳 **One-click Docker controls**: Restart / Rebuild the bot container and grant it disk access (mount any host folder at `/mnt/<name>`, cross-OS) right from the dashboard — restart your bot without touching the command line.
|
|
37
56
|
- ⚡ **Faster Dashboard & Bot pages**: Runtime/version detection now probes **once** and is cached instead of re-running on every page load — bot status dropped from ~4s to ~3ms on repeat loads. Cache auto-invalidates on update/rebuild/restart/install.
|
|
38
57
|
|
|
58
|
+
</details>
|
|
59
|
+
|
|
39
60
|
<details>
|
|
40
61
|
<summary><b>Previous: What's new in v5.8.24 (Launcher Auto-Update)</b></summary>
|
|
41
62
|
|
|
@@ -190,7 +211,7 @@ npx github:tuanminhhole/openclaw-setup
|
|
|
190
211
|
- **Telegram**: Acquire your official Bot Token from `@BotFather`.
|
|
191
212
|
- **Zalo Bot API**: Obtain credentials from [developers.zalo.me](https://developers.zalo.me).
|
|
192
213
|
- **Zalo Personal**: Scan the QR authorization image displayed on the OpenClaw Dashboard.
|
|
193
|
-
- **Facebook Messenger**: Via the
|
|
214
|
+
- **Facebook Messenger**: Via the `fb-messenger` plugin (public on ClawHub, installable from the Setup UI) — just provide a Page token.
|
|
194
215
|
- **Discord**: _Coming soon._
|
|
195
216
|
- **Lark**: _Coming soon._
|
|
196
217
|
|
|
@@ -275,7 +296,7 @@ Companion repos to build a complete, self-running AI assistant:
|
|
|
275
296
|
- [vietbrain](https://github.com/tuanminhhole/vietbrain) — Vietnamese "Second Brain" framework for Obsidian (AI-ready)
|
|
276
297
|
|
|
277
298
|
**🔌 Plugins (runtime)**
|
|
278
|
-
-
|
|
299
|
+
- [openclaw-fb-messenger](https://github.com/tuanminhhole/openclaw-fb-messenger) — Facebook Messenger channel (webhook + Graph API), installable from the Setup UI
|
|
279
300
|
- [openclaw-telegram-multibot-relay](https://github.com/tuanminhhole/openclaw-telegram-multibot-relay) — Multibot Telegram relay, delegation & native cron reminders
|
|
280
301
|
- [openclaw-zalo-mod](https://github.com/tuanminhhole/openclaw-zalo-mod) — Zero-token Zalo group management (slash commands, anti-spam, warn, memory)
|
|
281
302
|
- [openclaw-browser-automation](https://github.com/tuanminhhole/openclaw-browser-automation) — Smart Search & Browser Automation
|
|
@@ -283,7 +304,7 @@ Companion repos to build a complete, self-running AI assistant:
|
|
|
283
304
|
- [openclaw-n8n-facebook-poster](https://github.com/tuanminhhole/openclaw-n8n-facebook-poster) — Auto-post to Facebook via n8n
|
|
284
305
|
|
|
285
306
|
**🧩 Skills**
|
|
286
|
-
- [openclaw-skill-
|
|
307
|
+
- [openclaw-skill-learning-memory](https://github.com/tuanminhhole/openclaw-skill-learning-memory) — Self-evolving long-term memory for agents
|
|
287
308
|
- [openclaw-skill-infographic](https://github.com/tuanminhhole/openclaw-skill-infographic) — Infographic generation
|
|
288
309
|
- [openclaw-skill-zalo-sticker-mention](https://github.com/tuanminhhole/openclaw-skill-zalo-sticker-mention) — Stickers & mentions on Zalo
|
|
289
310
|
|
package/README.vi.md
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
### Một **giao diện Web** để tạo, triển khai & vận hành trợ lý AI đa bot miễn phí — không cần gõ terminal
|
|
6
6
|
|
|
7
|
-
*Chạy một lệnh → mở dashboard → bot lên sóng. Windows · macOS · Linux · VPS
|
|
7
|
+
*Chạy một lệnh → mở dashboard → bot lên sóng. Windows · macOS · Linux · VPS — chạy trên Docker, tự cài giúp bạn.*
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
10
|
-
<a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.
|
|
10
|
+
<a href="https://github.com/tuanminhhole/openclaw-setup/releases"><img src="https://img.shields.io/badge/RELEASE-v5.11.0-0EA5E9?style=for-the-badge" alt="Version 5.11.0" /></a>
|
|
11
11
|
<a href="https://github.com/tuanminhhole/openclaw-setup?tab=MIT-1-ov-file"><img src="https://img.shields.io/badge/LICENSE-MIT-success?style=for-the-badge" alt="MIT License" /></a>
|
|
12
12
|
<a href="https://www.npmjs.com/package/create-openclaw-bot"><img src="https://img.shields.io/npm/v/create-openclaw-bot?style=for-the-badge&label=CLI&color=2563EB&logo=npm&logoColor=white" alt="NPM Version" /></a>
|
|
13
13
|
<a href="https://github.com/tuanminhhole/openclaw-setup/stargazers"><img src="https://img.shields.io/github/stars/tuanminhhole/openclaw-setup?style=for-the-badge&color=eab308&logo=github&logoColor=white" alt="GitHub Stars" /></a>
|
|
@@ -27,7 +27,26 @@
|
|
|
27
27
|
|
|
28
28
|
---
|
|
29
29
|
|
|
30
|
-
## 🆕 Có gì mới trong v5.
|
|
30
|
+
## 🆕 Có gì mới trong v5.11.0
|
|
31
|
+
|
|
32
|
+
- 🚀 **Facebook Messenger, cài 1 chạm**: plugin `fb-messenger` giờ đã **public trên ClawHub** và cài ngay trên dashboard — tạo bot Messenger, mở **Bot → Plugins**, bấm **Cài** ở thẻ `openclaw-fb-messenger`. Webhook + Graph API, tự đổi User→Page token, xác minh HMAC.
|
|
33
|
+
- 🐳 **Chỉ còn Docker, cực ổn định**: loại bỏ kiểu cài native (không Docker) để tập trung cho luồng Docker chạy hoàn hảo & ổn định trên Windows / macOS / Linux / VPS, kèm tự cài Docker đa hệ điều hành.
|
|
34
|
+
- 🖥️ **Chrome-debug trên VPS headless**: relay Chrome-debug của browser-automation giờ chạy được trên VPS headless (giới hạn theo bridge-IP, tự mở ufw); trình sửa file bot cũng lưu được file text không phải `.md`.
|
|
35
|
+
|
|
36
|
+
<details>
|
|
37
|
+
<summary><b>Trước đó: Có gì mới trong v5.10.1</b></summary>
|
|
38
|
+
|
|
39
|
+
- 🔧 **Sửa lỗi Docker**: update plugin giữ nguyên port dashboard zalo-mod, và ổ đĩa/thư mục đã cấp quyền (kể cả nguyên ổ Windows như `D:\`) không còn bị mất sau khi rebuild.
|
|
40
|
+
- 🔄 **Tự khởi động lại tiến trình (native)**: cài native giờ đăng ký gateway + 9router thành service hệ điều hành (macOS launchd, Linux systemd, Windows detached), tự chạy lại khi crash/reboot — như `restart: always` của Docker. Best-effort, lỗi thì fallback chạy detached.
|
|
41
|
+
- 🔐 **Sync 9router lần đầu — đã sửa**: không còn tắt vĩnh viễn "Require login". `sync.js` đăng nhập bằng mật khẩu mặc định `123456`, tạo combo `smart-route` từ provider đang active **một lần** rồi dừng. Require login giữ ON (đổi mật khẩu sau); model-call `/v1` không bị ảnh hưởng.
|
|
42
|
+
- 📁 **Đường dẫn workspace native — đã sửa**: `workspace` của agent giờ là tương đối, persona/memory/skills nằm đúng chỗ khi cài native (trước là đường dẫn tuyệt đối kiểu container → trỏ sai trên host).
|
|
43
|
+
- 🧩 **Không ghi đè khối `meta` trong config**: generator ngừng seed `meta` để OpenClaw tự quản — tránh lỗi parse config do `lastTouchedVersion` là dải version.
|
|
44
|
+
- 💅 **UI dashboard gọn hơn**: nút tắt Bot/Cài đặt ở hero, badge version plugin, layout toggle tính năng, responsive/mobile, bỏ tiêu đề trùng ở tab dashboard.
|
|
45
|
+
|
|
46
|
+
</details>
|
|
47
|
+
|
|
48
|
+
<details>
|
|
49
|
+
<summary><b>Trước đó: Có gì mới trong v5.9.0</b></summary>
|
|
31
50
|
|
|
32
51
|
- 🧠 **TencentDB Agent Memory — cài 1 chạm**: Plugin bộ nhớ mới ngay trên UI. Bộ nhớ phân tầng 4 lớp (L0–L3) + nén ngữ cảnh, giữ session dài mạch lạc và **tiết kiệm tới ~61% token**. Chạy local hoàn toàn (SQLite), không cần API key, sẵn sàng cho Docker.
|
|
33
52
|
- ⚡ **Cấu hình tiết kiệm token mặc định cho mọi bot mới**: Tự có context pruning (cache-TTL) + compaction `safeguard` — **hội thoại dài rẻ hơn & sắc nét hơn**, không cần chỉnh tay.
|
|
@@ -36,6 +55,8 @@
|
|
|
36
55
|
- 🐳 **Nút điều khiển Docker 1 chạm**: Restart / Rebuild container bot và cấp quyền ổ đĩa (mount thư mục host bất kỳ vào `/mnt/<tên>`, đa OS) ngay trên dashboard — khởi động lại bot mà không cần gõ lệnh.
|
|
37
56
|
- ⚡ **Trang Dashboard & Bot load nhanh hơn**: Việc dò runtime/version giờ chỉ chạy **một lần** rồi cache thay vì lặp lại mỗi lần tải trang — bot status giảm từ ~4s xuống ~3ms ở các lần tải sau. Cache tự xoá khi update/rebuild/restart/cài đặt.
|
|
38
57
|
|
|
58
|
+
</details>
|
|
59
|
+
|
|
39
60
|
<details>
|
|
40
61
|
<summary><b>Trước đó: Có gì mới trong v5.8.24 (Tự động cập nhật Launcher)</b></summary>
|
|
41
62
|
|
|
@@ -190,7 +211,7 @@ npx github:tuanminhhole/openclaw-setup
|
|
|
190
211
|
- **Telegram**: Lấy token bot chính thức từ `@BotFather`.
|
|
191
212
|
- **Zalo Bot API**: Lấy thông tin kết nối chính thức từ [developers.zalo.me](https://developers.zalo.me).
|
|
192
213
|
- **Zalo Cá nhân (Zalo Personal)**: Kích hoạt cực nhanh bằng cách quét mã QR hiển thị ngay trên Dashboard OpenClaw.
|
|
193
|
-
- **Facebook Messenger**: Qua plugin
|
|
214
|
+
- **Facebook Messenger**: Qua plugin `fb-messenger` (public trên ClawHub, cài ngay trên giao diện Setup) — chỉ cần dùng Page token.
|
|
194
215
|
- **Discord**: _Sắp ra mắt._
|
|
195
216
|
- **Lark**: _Sắp ra mắt._
|
|
196
217
|
|
|
@@ -275,7 +296,7 @@ Các repo đi kèm để bạn dựng một trợ lý AI "tự vận hành" hoà
|
|
|
275
296
|
- [vietbrain](https://github.com/tuanminhhole/vietbrain) — Bộ khung "Bộ Não Thứ Hai" tiếng Việt cho Obsidian (sẵn sàng AI)
|
|
276
297
|
|
|
277
298
|
**🔌 Plugin (runtime)**
|
|
278
|
-
-
|
|
299
|
+
- [openclaw-fb-messenger](https://github.com/tuanminhhole/openclaw-fb-messenger) — Kênh Facebook Messenger (webhook + Graph API), cài ngay trên giao diện Setup
|
|
279
300
|
- [openclaw-telegram-multibot-relay](https://github.com/tuanminhhole/openclaw-telegram-multibot-relay) — Multibot Telegram relay, delegation & cron nhắc lịch native
|
|
280
301
|
- [openclaw-zalo-mod](https://github.com/tuanminhhole/openclaw-zalo-mod) — Quản lý nhóm Zalo zero-token (slash command, anti-spam, warn, memory)
|
|
281
302
|
- [openclaw-browser-automation](https://github.com/tuanminhhole/openclaw-browser-automation) — Smart Search & Browser Automation
|
|
@@ -283,7 +304,7 @@ Các repo đi kèm để bạn dựng một trợ lý AI "tự vận hành" hoà
|
|
|
283
304
|
- [openclaw-n8n-facebook-poster](https://github.com/tuanminhhole/openclaw-n8n-facebook-poster) — Tự động đăng Facebook qua n8n
|
|
284
305
|
|
|
285
306
|
**🧩 Skill**
|
|
286
|
-
- [openclaw-skill-
|
|
307
|
+
- [openclaw-skill-learning-memory](https://github.com/tuanminhhole/openclaw-skill-learning-memory) — Bộ nhớ dài hạn tự tiến hoá cho agent
|
|
287
308
|
- [openclaw-skill-infographic](https://github.com/tuanminhhole/openclaw-skill-infographic) — Tạo infographic
|
|
288
309
|
- [openclaw-skill-zalo-sticker-mention](https://github.com/tuanminhhole/openclaw-skill-zalo-sticker-mention) — Sticker & mention trên Zalo
|
|
289
310
|
|
|
@@ -388,9 +388,9 @@ async function getAvailableDrives() {
|
|
|
388
388
|
return drives.length ? drives : ['C:\\', 'D:\\'];
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
return '
|
|
391
|
+
// Docker is the only supported deploy mode now (native was removed).
|
|
392
|
+
function recommendedMode() {
|
|
393
|
+
return 'docker';
|
|
394
394
|
}
|
|
395
395
|
|
|
396
396
|
function commandExists(cmd, args = ['--version']) {
|
|
@@ -438,27 +438,6 @@ function run(cmd, args, opts = {}) {
|
|
|
438
438
|
});
|
|
439
439
|
}
|
|
440
440
|
|
|
441
|
-
function startDetached(cmd, args, opts = {}) {
|
|
442
|
-
sendLog(`$ ${cmd} ${args.join(' ')} &`);
|
|
443
|
-
const shell = process.platform === 'win32';
|
|
444
|
-
const rawBin = resolveBinPath(cmd);
|
|
445
|
-
const bin = shell && rawBin.includes(' ') && !rawBin.startsWith('"') ? `"${rawBin}"` : rawBin;
|
|
446
|
-
const child = spawn(bin, args, {
|
|
447
|
-
cwd: opts.cwd,
|
|
448
|
-
shell,
|
|
449
|
-
detached: true,
|
|
450
|
-
stdio: 'ignore',
|
|
451
|
-
windowsHide: opts.windowsHide ?? true,
|
|
452
|
-
env: { ...process.env, ...(opts.env || {}) },
|
|
453
|
-
});
|
|
454
|
-
child.on('error', (err) => {
|
|
455
|
-
sendLog(`[error] Failed to start background command "${cmd}": ${err.message}`);
|
|
456
|
-
console.error(`Failed to start background command "${cmd}":`, err);
|
|
457
|
-
});
|
|
458
|
-
child.unref();
|
|
459
|
-
return child.pid;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
441
|
async function getCurrentRuntimeVersions() {
|
|
463
442
|
const [openclaw, nineRouter, node] = await Promise.all([
|
|
464
443
|
commandExists('openclaw', ['--version']),
|
|
@@ -605,135 +584,6 @@ Timed out after ${opts.timeout}ms`.trim() : stderr });
|
|
|
605
584
|
});
|
|
606
585
|
}
|
|
607
586
|
|
|
608
|
-
// ── Native process supervision (auto-restart) ───────────────────────────────
|
|
609
|
-
// Docker containers get `restart: always` for free. For native installs we register the gateway
|
|
610
|
-
// and 9router as OS services so they survive crashes and reboots, mirroring that guarantee:
|
|
611
|
-
// • macOS → per-user launchd LaunchAgent (KeepAlive + RunAtLoad)
|
|
612
|
-
// • Linux → systemd unit (system unit when root, else --user) with Restart=always
|
|
613
|
-
// • Windows / anything else → plain detached process (no supervision; unchanged behaviour)
|
|
614
|
-
// Every step is best-effort: any failure falls back to startDetached, so a native install can
|
|
615
|
-
// never end up worse off than before this feature existed.
|
|
616
|
-
function escapeXml(s) { return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); }
|
|
617
|
-
function nativeServiceId(projectDir) { return slugify(basename(projectDir || 'openclaw')) || 'bot'; }
|
|
618
|
-
function nativeServiceLabels(projectDir) {
|
|
619
|
-
const id = nativeServiceId(projectDir);
|
|
620
|
-
return {
|
|
621
|
-
'9router': { launchd: `com.openclaw.9router.${id}`, systemd: `openclaw-9router-${id}.service` },
|
|
622
|
-
sync: { launchd: `com.openclaw.sync.${id}`, systemd: `openclaw-sync-${id}.service` },
|
|
623
|
-
gateway: { launchd: `com.openclaw.gateway.${id}`, systemd: `openclaw-gateway-${id}.service` },
|
|
624
|
-
};
|
|
625
|
-
}
|
|
626
|
-
async function resolveAbsoluteBin(cmd) {
|
|
627
|
-
try {
|
|
628
|
-
const out = await runCapture(process.platform === 'win32' ? 'where' : 'which', [cmd], { shell: false, timeout: 5000 });
|
|
629
|
-
const first = String(out.stdout || '').split(/\r?\n/).map((s) => s.trim()).find(Boolean);
|
|
630
|
-
if (out.code === 0 && first) return first;
|
|
631
|
-
} catch {}
|
|
632
|
-
return resolveBinPath(cmd);
|
|
633
|
-
}
|
|
634
|
-
function systemdUserUnitDir() { return join(getRealHomedir(), '.config', 'systemd', 'user'); }
|
|
635
|
-
function isRootUser() { return typeof process.getuid === 'function' && process.getuid() === 0; }
|
|
636
|
-
|
|
637
|
-
/**
|
|
638
|
-
* Start one native process under OS supervision (auto-restart). Falls back to a plain detached
|
|
639
|
-
* process on Windows/unknown platforms or if the service tooling errors. Returns the method used:
|
|
640
|
-
* 'launchd' | 'systemd' | 'detached'.
|
|
641
|
-
*/
|
|
642
|
-
async function startNativeService({ projectDir, name, cmd, args, desc, env }) {
|
|
643
|
-
const labels = nativeServiceLabels(projectDir)[name];
|
|
644
|
-
const logDir = join(projectDir, '.openclaw', 'logs');
|
|
645
|
-
await fsp.mkdir(logDir, { recursive: true }).catch(() => {});
|
|
646
|
-
const serviceEnv = { PATH: process.env.PATH || '', HOME: getRealHomedir(), ...env };
|
|
647
|
-
try {
|
|
648
|
-
const absBin = await resolveAbsoluteBin(cmd);
|
|
649
|
-
if (process.platform === 'darwin') {
|
|
650
|
-
const agentsDir = join(getRealHomedir(), 'Library', 'LaunchAgents');
|
|
651
|
-
await fsp.mkdir(agentsDir, { recursive: true });
|
|
652
|
-
const progArgs = [absBin, ...args].map((a) => ` <string>${escapeXml(a)}</string>`).join('\n');
|
|
653
|
-
const envDict = Object.entries(serviceEnv).map(([k, v]) => ` <key>${escapeXml(k)}</key><string>${escapeXml(String(v))}</string>`).join('\n');
|
|
654
|
-
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
655
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
656
|
-
<plist version="1.0"><dict>
|
|
657
|
-
<key>Label</key><string>${labels.launchd}</string>
|
|
658
|
-
<key>ProgramArguments</key><array>
|
|
659
|
-
${progArgs}
|
|
660
|
-
</array>
|
|
661
|
-
<key>WorkingDirectory</key><string>${escapeXml(projectDir)}</string>
|
|
662
|
-
<key>EnvironmentVariables</key><dict>
|
|
663
|
-
${envDict}
|
|
664
|
-
</dict>
|
|
665
|
-
<key>RunAtLoad</key><true/>
|
|
666
|
-
<key>KeepAlive</key><true/>
|
|
667
|
-
<key>StandardOutPath</key><string>${escapeXml(join(logDir, name + '.out.log'))}</string>
|
|
668
|
-
<key>StandardErrorPath</key><string>${escapeXml(join(logDir, name + '.err.log'))}</string>
|
|
669
|
-
</dict></plist>`;
|
|
670
|
-
const plistPath = join(agentsDir, `${labels.launchd}.plist`);
|
|
671
|
-
await fsp.writeFile(plistPath, plist, 'utf8');
|
|
672
|
-
await run('launchctl', ['unload', plistPath], {}).catch(() => {});
|
|
673
|
-
await run('launchctl', ['load', '-w', plistPath], {});
|
|
674
|
-
sendLog(`[native] launchd service ${labels.launchd} loaded (auto-restart)`);
|
|
675
|
-
return 'launchd';
|
|
676
|
-
}
|
|
677
|
-
if (process.platform === 'linux') {
|
|
678
|
-
const root = isRootUser();
|
|
679
|
-
const unitDir = root ? '/etc/systemd/system' : systemdUserUnitDir();
|
|
680
|
-
await fsp.mkdir(unitDir, { recursive: true });
|
|
681
|
-
const sc = (a) => run('systemctl', root ? a : ['--user', ...a], {});
|
|
682
|
-
const envLines = Object.entries(serviceEnv).map(([k, v]) => `Environment="${k}=${String(v).replace(/\n/g, ' ')}"`).join('\n');
|
|
683
|
-
const execStart = [absBin, ...args].map((a) => (/[\s"']/.test(a) ? JSON.stringify(a) : a)).join(' ');
|
|
684
|
-
const unit = `[Unit]
|
|
685
|
-
Description=${desc}
|
|
686
|
-
After=network-online.target
|
|
687
|
-
Wants=network-online.target
|
|
688
|
-
|
|
689
|
-
[Service]
|
|
690
|
-
Type=simple
|
|
691
|
-
WorkingDirectory=${projectDir}
|
|
692
|
-
${envLines}
|
|
693
|
-
ExecStart=${execStart}
|
|
694
|
-
Restart=always
|
|
695
|
-
RestartSec=3
|
|
696
|
-
|
|
697
|
-
[Install]
|
|
698
|
-
WantedBy=${root ? 'multi-user.target' : 'default.target'}
|
|
699
|
-
`;
|
|
700
|
-
await fsp.writeFile(join(unitDir, labels.systemd), unit, 'utf8');
|
|
701
|
-
await sc(['daemon-reload']);
|
|
702
|
-
await sc(['enable', '--now', labels.systemd]);
|
|
703
|
-
sendLog(`[native] systemd ${root ? 'system' : 'user'} service ${labels.systemd} enabled (Restart=always)`);
|
|
704
|
-
return 'systemd';
|
|
705
|
-
}
|
|
706
|
-
} catch (e) {
|
|
707
|
-
sendLog(`[native] ${name} service setup failed (${e.message}); falling back to detached process`);
|
|
708
|
-
}
|
|
709
|
-
const pid = startDetached(cmd, args, { cwd: projectDir, env });
|
|
710
|
-
if (name === 'gateway') state.botPid = pid;
|
|
711
|
-
return 'detached';
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
/** Remove any launchd/systemd services registered for a native project (used on delete). */
|
|
715
|
-
async function removeNativeAutostart(projectDir) {
|
|
716
|
-
const labels = nativeServiceLabels(projectDir);
|
|
717
|
-
try {
|
|
718
|
-
if (process.platform === 'darwin') {
|
|
719
|
-
const agentsDir = join(getRealHomedir(), 'Library', 'LaunchAgents');
|
|
720
|
-
for (const k of ['9router', 'sync', 'gateway']) {
|
|
721
|
-
const p = join(agentsDir, `${labels[k].launchd}.plist`);
|
|
722
|
-
if (existsSync(p)) { await run('launchctl', ['unload', p], {}).catch(() => {}); await fsp.unlink(p).catch(() => {}); }
|
|
723
|
-
}
|
|
724
|
-
} else if (process.platform === 'linux') {
|
|
725
|
-
const root = isRootUser();
|
|
726
|
-
const unitDir = root ? '/etc/systemd/system' : systemdUserUnitDir();
|
|
727
|
-
const sc = (a) => run('systemctl', root ? a : ['--user', ...a], {}).catch(() => {});
|
|
728
|
-
let changed = false;
|
|
729
|
-
for (const k of ['9router', 'sync', 'gateway']) {
|
|
730
|
-
const u = join(unitDir, labels[k].systemd);
|
|
731
|
-
if (existsSync(u)) { await sc(['disable', '--now', labels[k].systemd]); await fsp.unlink(u).catch(() => {}); changed = true; }
|
|
732
|
-
}
|
|
733
|
-
if (changed) await sc(['daemon-reload']);
|
|
734
|
-
}
|
|
735
|
-
} catch (e) { sendLog(`[native] autostart teardown skipped: ${e.message}`); }
|
|
736
|
-
}
|
|
737
587
|
|
|
738
588
|
function safeJoin(root, name) {
|
|
739
589
|
const clean = normalize(String(name || '')).replace(/^([/\\])+/, '');
|
|
@@ -2079,104 +1929,7 @@ try{
|
|
|
2079
1929
|
return { message: 'Generating Zalo QR. The image will appear automatically.' };
|
|
2080
1930
|
}
|
|
2081
1931
|
|
|
2082
|
-
|
|
2083
|
-
// Same flow as Docker but on the host: run `openclaw channels login` directly,
|
|
2084
|
-
// read the QR PNG straight off the host filesystem, then restart the native
|
|
2085
|
-
// gateway so the saved credentials take effect.
|
|
2086
|
-
const gatewayPort = state.gatewayPort || 18789;
|
|
2087
|
-
const runtimeEnv = {
|
|
2088
|
-
...process.env,
|
|
2089
|
-
OPENCLAW_HOME: join(projectDir, '.openclaw'),
|
|
2090
|
-
OPENCLAW_STATE_DIR: join(projectDir, '.openclaw'),
|
|
2091
|
-
OPENCLAW_GATEWAY_PORT: String(gatewayPort),
|
|
2092
|
-
OPENCLAW_PORT: String(gatewayPort),
|
|
2093
|
-
};
|
|
2094
|
-
|
|
2095
|
-
// The runtime writes the QR to a uid-scoped temp dir; probe the likely locations.
|
|
2096
|
-
const uid = (typeof process.getuid === 'function') ? process.getuid() : '';
|
|
2097
|
-
const qrNames = profile === 'default'
|
|
2098
|
-
? ['openclaw-zalouser-qr.png', 'openclaw-zalouser-qr-default.png', `openclaw-zalouser-qr-${profile}.png`]
|
|
2099
|
-
: [`openclaw-zalouser-qr-${profile}.png`];
|
|
2100
|
-
const qrDirs = [join(os.tmpdir(), 'openclaw'), '/tmp/openclaw', `/tmp/openclaw-${uid}`, '/tmp/openclaw-1000', join(os.tmpdir(), `openclaw-${uid}`)];
|
|
2101
|
-
const nativeQrPaths = [...new Set(qrDirs.flatMap((d) => qrNames.map((n) => join(d, n))))];
|
|
2102
|
-
|
|
2103
|
-
// Clean stale credentials & QR files so we only ever surface a fresh code.
|
|
2104
|
-
const credFile = profile === 'default' ? 'credentials.json' : `credentials-${profile}.json`;
|
|
2105
|
-
const credPath = join(projectDir, '.openclaw', 'credentials', 'zalouser', credFile);
|
|
2106
|
-
try { await fsp.rm(credPath, { force: true }); } catch {}
|
|
2107
|
-
for (const p of nativeQrPaths) { try { await fsp.rm(p, { force: true }); } catch {} }
|
|
2108
|
-
|
|
2109
|
-
sendLog('[zalouser] Generating Zalo QR (native). The image will appear automatically.');
|
|
2110
|
-
|
|
2111
|
-
const MAX_LOGIN_ATTEMPTS = 4;
|
|
2112
|
-
const RETRY_DELAYS = [0, 8000, 15000, 20000];
|
|
2113
|
-
let loginAttempt = 0;
|
|
2114
|
-
let sent = false;
|
|
2115
|
-
let restartAfterLogin = false;
|
|
2116
|
-
|
|
2117
|
-
// Poll the host filesystem for the QR PNG across all retry attempts.
|
|
2118
|
-
let tries = 0;
|
|
2119
|
-
const poll = setInterval(() => {
|
|
2120
|
-
if (sent || tries++ > 120) {
|
|
2121
|
-
clearInterval(poll);
|
|
2122
|
-
if (!sent) sendLog('[zalouser] QR not found yet. Try closing/reopening login, or run `openclaw channels login --channel zalouser --verbose` on the host.');
|
|
2123
|
-
return;
|
|
2124
|
-
}
|
|
2125
|
-
for (const p of nativeQrPaths) {
|
|
2126
|
-
try {
|
|
2127
|
-
if (existsSync(p) && fs.statSync(p).size > 100) {
|
|
2128
|
-
const b64 = fs.readFileSync(p).toString('base64');
|
|
2129
|
-
if (b64.length > 100) {
|
|
2130
|
-
sent = true;
|
|
2131
|
-
clearInterval(poll);
|
|
2132
|
-
sendLog(`[zalouser:qr] data:image/png;base64,${b64}`);
|
|
2133
|
-
sendLog('[zalouser] Scan this QR with the Zalo app.');
|
|
2134
|
-
break;
|
|
2135
|
-
}
|
|
2136
|
-
}
|
|
2137
|
-
} catch {}
|
|
2138
|
-
}
|
|
2139
|
-
}, 1000);
|
|
2140
|
-
|
|
2141
|
-
const loginArgs = ['channels', 'login', '--channel', 'zalouser', '--account', profile, '--verbose'];
|
|
2142
|
-
const runLoginAttempt = () => {
|
|
2143
|
-
loginAttempt++;
|
|
2144
|
-
if (loginAttempt > 1) sendLog(`[zalouser] Retry attempt ${loginAttempt}/${MAX_LOGIN_ATTEMPTS}...`);
|
|
2145
|
-
const child = spawn('openclaw', loginArgs, { cwd: projectDir, env: runtimeEnv, shell: false, windowsHide: true });
|
|
2146
|
-
const handleLoginLine = (line) => {
|
|
2147
|
-
sendLog(line);
|
|
2148
|
-
if (/login successful|saved auth/i.test(line)) restartAfterLogin = true;
|
|
2149
|
-
};
|
|
2150
|
-
child.stdout.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLoginLine));
|
|
2151
|
-
child.stderr.on('data', (d) => String(d).split(/\r?\n/).filter(Boolean).forEach(handleLoginLine));
|
|
2152
|
-
child.on('error', (err) => sendLog(`[zalouser] Login process failed: ${err.message}. Is the 'openclaw' CLI installed on PATH?`));
|
|
2153
|
-
child.on('close', async (code) => {
|
|
2154
|
-
sendLog(`[zalouser] Login process exited ${code}`);
|
|
2155
|
-
if (code === 0 || restartAfterLogin || sent) {
|
|
2156
|
-
if (restartAfterLogin) {
|
|
2157
|
-
sendLog('[zalouser] Login saved. Restarting native gateway so Zalo User can receive messages...');
|
|
2158
|
-
try {
|
|
2159
|
-
await run('openclaw', ['gateway', 'stop'], { cwd: projectDir, env: runtimeEnv });
|
|
2160
|
-
state.botPid = startDetached('openclaw', ['gateway', 'run'], { cwd: projectDir, env: runtimeEnv });
|
|
2161
|
-
sendLog(`[zalouser] Native gateway restarted (pid=${state.botPid || 'unknown'}). Try sending a Zalo message now.`);
|
|
2162
|
-
} catch (err) {
|
|
2163
|
-
sendLog(`[zalouser] Gateway restart failed: ${err.message}. Restart it manually: openclaw gateway run`);
|
|
2164
|
-
}
|
|
2165
|
-
}
|
|
2166
|
-
zaloLoginInFlight = false;
|
|
2167
|
-
} else if (loginAttempt < MAX_LOGIN_ATTEMPTS && !sent) {
|
|
2168
|
-
const delay = RETRY_DELAYS[loginAttempt] || 10000;
|
|
2169
|
-
sendLog(`[zalouser] QR not ready yet. Waiting ${delay / 1000}s before retry...`);
|
|
2170
|
-
setTimeout(runLoginAttempt, delay);
|
|
2171
|
-
} else {
|
|
2172
|
-
sendLog('[zalouser] All login attempts exhausted. Try clicking "Đăng nhập Zalo" again.');
|
|
2173
|
-
zaloLoginInFlight = false;
|
|
2174
|
-
}
|
|
2175
|
-
});
|
|
2176
|
-
};
|
|
2177
|
-
|
|
2178
|
-
runLoginAttempt();
|
|
2179
|
-
return { message: 'Generating Zalo QR (native). The image will appear automatically.' };
|
|
1932
|
+
throw httpError(400, 'Zalo login cần project Docker đang chạy (không tìm thấy docker-compose.yml).');
|
|
2180
1933
|
}
|
|
2181
1934
|
|
|
2182
1935
|
function getBotServiceName(projectDir) {
|
|
@@ -2277,6 +2030,13 @@ async function syncDockerInfra(projectDir, force = false) {
|
|
|
2277
2030
|
|
|
2278
2031
|
sendLog(`[sync] Updating Docker infrastructure files (v${existingVersion} \u2192 v${SETUP_VERSION})`);
|
|
2279
2032
|
await fsp.writeFile(join(dockerDir, 'Dockerfile'), docker.dockerfile, 'utf8');
|
|
2033
|
+
// Capture the user's custom disk/folder mounts from the OLD compose before we overwrite it — a
|
|
2034
|
+
// full regen only re-emits the default volumes, so without this the bot loses granted drives.
|
|
2035
|
+
let carriedMounts = [];
|
|
2036
|
+
try {
|
|
2037
|
+
const prevCompose = join(dockerDir, 'docker-compose.yml');
|
|
2038
|
+
if (existsSync(prevCompose)) carriedMounts = parseComposeMounts(await fsp.readFile(prevCompose, 'utf8'));
|
|
2039
|
+
} catch {}
|
|
2280
2040
|
await fsp.writeFile(join(dockerDir, 'docker-compose.yml'), newCompose, 'utf8');
|
|
2281
2041
|
// Preserve zalo-mod dashboard port if plugin is active
|
|
2282
2042
|
try {
|
|
@@ -2287,14 +2047,29 @@ async function syncDockerInfra(projectDir, force = false) {
|
|
|
2287
2047
|
let cc = await fsp.readFile(join(dockerDir, 'docker-compose.yml'), 'utf8');
|
|
2288
2048
|
if (!cc.includes(`:${dp}`)) {
|
|
2289
2049
|
const gpStr = String(gatewayPort);
|
|
2050
|
+
// Match the gateway published-port line whatever the host prefix is — the generated form is
|
|
2051
|
+
// "127.0.0.1:<gw>:<gw>", so keying off the container port (":<gw>" before the quote) is the
|
|
2052
|
+
// only reliable anchor. The old `(?:\d+:)?` variant never matched the "127.0.0.1:" prefix.
|
|
2290
2053
|
cc = cc.replace(
|
|
2291
|
-
new RegExp(`^(\\s*-\\s*"
|
|
2054
|
+
new RegExp(`^(\\s*-\\s*"[^"\\n]*:${gpStr}")\\s*$`, 'm'),
|
|
2292
2055
|
`$1\n - "127.0.0.1:${dp}:${dp}" # zalo-mod dashboard`
|
|
2293
2056
|
);
|
|
2294
2057
|
await fsp.writeFile(join(dockerDir, 'docker-compose.yml'), cc, 'utf8');
|
|
2295
2058
|
}
|
|
2296
2059
|
}
|
|
2297
2060
|
} catch {}
|
|
2061
|
+
// Re-inject the user's custom mounts carried over from the old compose.
|
|
2062
|
+
if (carriedMounts.length) {
|
|
2063
|
+
try {
|
|
2064
|
+
const cpath = join(dockerDir, 'docker-compose.yml');
|
|
2065
|
+
const cur = await fsp.readFile(cpath, 'utf8');
|
|
2066
|
+
const merged = injectMountsIntoCompose(cur, carriedMounts);
|
|
2067
|
+
if (merged !== cur) {
|
|
2068
|
+
await fsp.writeFile(cpath, merged, 'utf8');
|
|
2069
|
+
sendLog(`[sync] Preserved ${carriedMounts.length} granted mount(s) across the compose regen`);
|
|
2070
|
+
}
|
|
2071
|
+
} catch (e) { sendLog(`[sync] Warning: could not re-apply granted mounts: ${e.message}`); }
|
|
2072
|
+
}
|
|
2298
2073
|
await fsp.writeFile(entrypointPath, entryScript, 'utf8');
|
|
2299
2074
|
if (docker.syncScript) await fsp.writeFile(join(dockerDir, 'sync.js'), docker.syncScript, 'utf8');
|
|
2300
2075
|
if (docker.patchScript) await fsp.writeFile(join(dockerDir, 'patch-9router.js'), docker.patchScript, 'utf8');
|
|
@@ -2370,16 +2145,7 @@ async function updateRuntime(target, projectDir) {
|
|
|
2370
2145
|
probeCacheClear();
|
|
2371
2146
|
return { ok: true, target, spec, mode: 'docker' };
|
|
2372
2147
|
}
|
|
2373
|
-
|
|
2374
|
-
if (isRouter) {
|
|
2375
|
-
await run('openclaw', ['gateway', 'stop'], { cwd: projectDir }).catch(() => {});
|
|
2376
|
-
await run('npm', ['install', '-g', NINE_ROUTER_NPM_SPEC]);
|
|
2377
|
-
} else {
|
|
2378
|
-
await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
|
|
2379
|
-
}
|
|
2380
|
-
await syncRuntimeState(projectDir, { full: true }).catch(() => {});
|
|
2381
|
-
probeCacheClear();
|
|
2382
|
-
return { ok: true, target, spec, mode: 'native' };
|
|
2148
|
+
throw httpError(400, 'Không có project Docker để cập nhật runtime.');
|
|
2383
2149
|
}
|
|
2384
2150
|
|
|
2385
2151
|
async function restartDockerBotContainer(projectDir = state.projectDir) {
|
|
@@ -2392,6 +2158,45 @@ async function restartDockerBotContainer(projectDir = state.projectDir) {
|
|
|
2392
2158
|
return true;
|
|
2393
2159
|
}
|
|
2394
2160
|
|
|
2161
|
+
// Parse user-granted disk/folder mounts (/mnt/<name>, excluding the always-present /mnt/project)
|
|
2162
|
+
// from a docker-compose.yml string. Handles both the long-form bind (type/source/target) and the
|
|
2163
|
+
// legacy short form `- "<host>:/mnt/<name>"`.
|
|
2164
|
+
function parseComposeMounts(compose) {
|
|
2165
|
+
const mounts = [];
|
|
2166
|
+
const lines = String(compose || '').split('\n');
|
|
2167
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2168
|
+
const tm = lines[i].match(/^\s*target:\s*['"]?(\/mnt\/[A-Za-z0-9._-]+)['"]?\s*$/);
|
|
2169
|
+
if (tm && tm[1] !== '/mnt/project') {
|
|
2170
|
+
let host = '';
|
|
2171
|
+
for (let j = i - 1; j >= Math.max(0, i - 3); j--) {
|
|
2172
|
+
const sm = lines[j].match(/^\s*source:\s*['"]?(.+?)['"]?\s*$/);
|
|
2173
|
+
if (sm) { host = sm[1].replace(/''/g, "'"); break; }
|
|
2174
|
+
}
|
|
2175
|
+
if (host) mounts.push({ host, target: tm[1] });
|
|
2176
|
+
continue;
|
|
2177
|
+
}
|
|
2178
|
+
const ssm = lines[i].match(/^\s*-\s*"?([^":\n]+):(\/mnt\/[A-Za-z0-9._-]+)"?\s*$/);
|
|
2179
|
+
if (ssm && ssm[2] !== '/mnt/project') mounts.push({ host: ssm[1].trim(), target: ssm[2] });
|
|
2180
|
+
}
|
|
2181
|
+
return mounts;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
// Insert long-form bind mounts (idempotent) after the project's .openclaw volume line. Returns the
|
|
2185
|
+
// original string unchanged if the anchor is missing or a mount is already present.
|
|
2186
|
+
function injectMountsIntoCompose(compose, mounts) {
|
|
2187
|
+
const anchor = /^(\s*-\s*\.\.\/\.\.\/\.openclaw:\/home\/node\/project\/\.openclaw)\s*$/m;
|
|
2188
|
+
if (!Array.isArray(mounts) || !mounts.length || !anchor.test(compose)) return compose;
|
|
2189
|
+
let out = compose;
|
|
2190
|
+
for (const m of mounts) {
|
|
2191
|
+
if (!m || !m.host || !m.target) continue;
|
|
2192
|
+
if (out.includes(`target: ${m.target}`) || out.includes(`:${m.target}"`) || out.includes(`:${m.target}\n`) || out.includes(m.host)) continue;
|
|
2193
|
+
const src = `'${String(m.host).replace(/'/g, "''")}'`;
|
|
2194
|
+
const block = ` - type: bind\n source: ${src}\n target: ${m.target}`;
|
|
2195
|
+
out = out.replace(anchor, `$1\n${block}`);
|
|
2196
|
+
}
|
|
2197
|
+
return out;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2395
2200
|
// Grant the bot access to a host disk/folder by mounting it into the container at /mnt/<name>.
|
|
2396
2201
|
// Project-scoped: all bots in this project share the container, so all of them can use it.
|
|
2397
2202
|
// Per-bot limits are described in AGENTS.md (not enforced at the mount layer).
|
|
@@ -2399,7 +2204,10 @@ async function addBotMount(projectDir, hostPath, mountName = '') {
|
|
|
2399
2204
|
// Cross-OS normalize: trim, convert Windows backslashes → forward slashes (Docker accepts
|
|
2400
2205
|
// forward slashes on every OS, incl. `C:/Users/...`), drop trailing separators. This avoids
|
|
2401
2206
|
// YAML backslash issues and keeps the path uniform.
|
|
2402
|
-
|
|
2207
|
+
let cleanPath = String(hostPath || '').trim().replace(/\\+/g, '/').replace(/\/+$/, '');
|
|
2208
|
+
// A bare Windows drive letter ("D:") is an INVALID Docker bind source — the trailing-slash strip
|
|
2209
|
+
// above turns "D:/" into "D:". Restore the slash so mounting a whole drive (e.g. D:\) works.
|
|
2210
|
+
if (/^[a-zA-Z]:$/.test(cleanPath)) cleanPath += '/';
|
|
2403
2211
|
if (!cleanPath) throw httpError(400, 'Đường dẫn ổ đĩa/thư mục đang trống');
|
|
2404
2212
|
const composeFile = join(projectDir, 'docker', 'openclaw', 'docker-compose.yml');
|
|
2405
2213
|
if (!existsSync(composeFile)) throw httpError(400, 'Không tìm thấy docker-compose.yml (project có thể không chạy ở chế độ Docker)');
|
|
@@ -2540,6 +2348,192 @@ async function writeCoreProject({ projectDir, osChoice, mode, gatewayPort = 1878
|
|
|
2540
2348
|
}
|
|
2541
2349
|
}
|
|
2542
2350
|
|
|
2351
|
+
// Locate a real Chrome/Chromium binary on the host (for the "grant Chrome to the bot" button).
|
|
2352
|
+
async function findChromeBinary() {
|
|
2353
|
+
if (process.platform === 'darwin') {
|
|
2354
|
+
for (const p of [
|
|
2355
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
2356
|
+
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
|
2357
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
2358
|
+
]) if (existsSync(p)) return p;
|
|
2359
|
+
return '';
|
|
2360
|
+
}
|
|
2361
|
+
if (process.platform === 'win32') {
|
|
2362
|
+
for (const p of [
|
|
2363
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
2364
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
2365
|
+
join(process.env.LOCALAPPDATA || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
2366
|
+
]) if (p && existsSync(p)) return p;
|
|
2367
|
+
return '';
|
|
2368
|
+
}
|
|
2369
|
+
for (const c of ['google-chrome', 'google-chrome-stable', 'chromium-browser', 'chromium']) {
|
|
2370
|
+
const r = await commandExists(c, ['--version']);
|
|
2371
|
+
if (r.ok) return c;
|
|
2372
|
+
}
|
|
2373
|
+
return '';
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
// TCP relay for headless VPS: `ssh -R 9222:...` binds the VPS loopback only (sshd GatewayPorts
|
|
2377
|
+
// defaults to "no"), which the bot container cannot reach. This relay listens on the docker
|
|
2378
|
+
// bridge IP (host.docker.internal from inside the container) and pipes to the loopback tunnel.
|
|
2379
|
+
let _chromeRelayServer = null;
|
|
2380
|
+
async function getDockerBridgeIp() {
|
|
2381
|
+
try {
|
|
2382
|
+
const out = await runCapture('sh', ['-c', "ip -4 -o addr show docker0 | awk '{print $4}' | cut -d/ -f1"], { shell: false, timeout: 4000 });
|
|
2383
|
+
const ip = String(out.stdout || '').trim();
|
|
2384
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) return ip;
|
|
2385
|
+
} catch {}
|
|
2386
|
+
return '172.17.0.1';
|
|
2387
|
+
}
|
|
2388
|
+
async function ensureChromeRelay() {
|
|
2389
|
+
if (_chromeRelayServer) return true;
|
|
2390
|
+
const bridgeIp = await getDockerBridgeIp();
|
|
2391
|
+
return new Promise((resolveP) => {
|
|
2392
|
+
const relay = net.createServer((client) => {
|
|
2393
|
+
const upstream = net.connect(9222, '127.0.0.1');
|
|
2394
|
+
client.pipe(upstream).pipe(client);
|
|
2395
|
+
client.on('error', () => upstream.destroy());
|
|
2396
|
+
upstream.on('error', () => client.destroy());
|
|
2397
|
+
});
|
|
2398
|
+
relay.once('error', () => resolveP(false)); // EADDRINUSE etc. → likely already relayed
|
|
2399
|
+
relay.listen(9222, bridgeIp, () => {
|
|
2400
|
+
_chromeRelayServer = relay;
|
|
2401
|
+
sendLog(`[chrome] Relay ${bridgeIp}:9222 → 127.0.0.1:9222 sẵn sàng (chờ SSH tunnel từ máy bạn).`);
|
|
2402
|
+
// Ubuntu VPSes usually run ufw with default-deny INPUT, which silently drops container→host
|
|
2403
|
+
// traffic to the relay. Open the port scoped to the PRIVATE bridge IP only (not reachable
|
|
2404
|
+
// from the internet). Best-effort; `ufw allow` skips duplicates on re-runs.
|
|
2405
|
+
run('sh', ['-c', `command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active" && ufw allow in to ${bridgeIp} port 9222 proto tcp comment "openclaw chrome-debug relay (docker bridge only)" || true`])
|
|
2406
|
+
.catch(() => sendLog('[chrome] Không thể tự mở firewall cho relay — nếu bot không thấy Chrome, chạy: ufw allow in to ' + bridgeIp + ' port 9222 proto tcp'));
|
|
2407
|
+
resolveP(true);
|
|
2408
|
+
});
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
// Launch real host Chrome in remote-debugging mode (port 9222) so the browser-automation plugin
|
|
2413
|
+
// can drive the user's actual Chrome (logged-in profile) instead of headless Chromium. The bot
|
|
2414
|
+
// reaches it via CDP (host.docker.internal:9222 from the container). Detached: keeps running after
|
|
2415
|
+
// this request. `--remote-allow-origins=*` is required by modern Chrome for cross-origin CDP.
|
|
2416
|
+
// On a headless VPS there is no Chrome to open here — instead we start the bridge relay and hand
|
|
2417
|
+
// back copy-paste commands so the user runs Chrome on THEIR machine + a reverse SSH tunnel.
|
|
2418
|
+
async function startChromeDebug() {
|
|
2419
|
+
if (isHeadlessServer()) {
|
|
2420
|
+
await ensureChromeRelay();
|
|
2421
|
+
const ip = (await getPublicIp().catch(() => '')) || '<IP-VPS>';
|
|
2422
|
+
const user = sshUserName();
|
|
2423
|
+
return {
|
|
2424
|
+
ok: true,
|
|
2425
|
+
headless: true,
|
|
2426
|
+
port: 9222,
|
|
2427
|
+
chromeCmdMac: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --remote-debugging-port=9222 --user-data-dir="$HOME/.openclaw-chrome-debug" --remote-allow-origins='*'`,
|
|
2428
|
+
chromeCmdWin: `"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" --remote-debugging-port=9222 --user-data-dir=%TEMP%\\openclaw-chrome-debug --remote-allow-origins=*`,
|
|
2429
|
+
tunnelCmd: `ssh -N -R 9222:127.0.0.1:9222 ${user}@${ip}`,
|
|
2430
|
+
};
|
|
2431
|
+
}
|
|
2432
|
+
const bin = await findChromeBinary();
|
|
2433
|
+
if (!bin) {
|
|
2434
|
+
throw httpError(400, process.platform === 'linux'
|
|
2435
|
+
? 'Không tìm thấy Chrome/Chromium trên máy. Cài google-chrome hoặc chromium rồi thử lại (không áp dụng cho VPS không có giao diện).'
|
|
2436
|
+
: 'Không tìm thấy Google Chrome. Hãy cài Chrome rồi thử lại.');
|
|
2437
|
+
}
|
|
2438
|
+
const port = 9222;
|
|
2439
|
+
const userDataDir = join(os.tmpdir(), 'openclaw-chrome-debug');
|
|
2440
|
+
const args = [
|
|
2441
|
+
`--remote-debugging-port=${port}`,
|
|
2442
|
+
`--user-data-dir=${userDataDir}`,
|
|
2443
|
+
'--remote-allow-origins=*',
|
|
2444
|
+
'--no-first-run',
|
|
2445
|
+
'--no-default-browser-check',
|
|
2446
|
+
];
|
|
2447
|
+
const child = spawn(bin, args, { detached: true, stdio: 'ignore', windowsHide: false });
|
|
2448
|
+
child.on('error', (e) => sendLog(`[chrome] Không mở được Chrome debug: ${e.message}`));
|
|
2449
|
+
child.unref();
|
|
2450
|
+
sendLog(`[chrome] Đã mở Chrome debug ở cổng ${port} (${bin}). Bot sẽ ưu tiên dùng Chrome này.`);
|
|
2451
|
+
return { ok: true, port, browser: bin, userDataDir };
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
// Poll until the Docker daemon responds (or timeout). Returns true if ready.
|
|
2455
|
+
async function waitForDockerDaemon(timeoutMs) {
|
|
2456
|
+
const deadline = Date.now() + timeoutMs;
|
|
2457
|
+
for (;;) {
|
|
2458
|
+
const ok = await commandExists('docker', ['version', '--format', '{{.Server.Version}}']);
|
|
2459
|
+
if (ok.ok) return true;
|
|
2460
|
+
if (Date.now() >= deadline) return false;
|
|
2461
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
// Ensure Docker (engine + compose) is available before a docker-mode install, auto-installing the
|
|
2466
|
+
// latest version appropriate for the host OS when it is missing:
|
|
2467
|
+
// • Linux → Docker's official convenience script (get.docker.com; auto-detects the distro) + start daemon
|
|
2468
|
+
// • macOS → Docker Desktop via Homebrew cask, then launch it
|
|
2469
|
+
// • Windows → Docker Desktop via winget (fallback Chocolatey), then launch it
|
|
2470
|
+
// macOS/Windows Docker Desktop needs a GUI/WSL startup (and sometimes a reboot), so we install +
|
|
2471
|
+
// launch + wait, and give a clear next-step if the daemon still isn't up when we time out.
|
|
2472
|
+
async function ensureDockerInstalled(osChoice) {
|
|
2473
|
+
if (await waitForDockerDaemon(0)) return; // already running
|
|
2474
|
+
const cliOk = await commandExists('docker', ['--version']);
|
|
2475
|
+
|
|
2476
|
+
if (process.platform === 'linux') {
|
|
2477
|
+
const root = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
2478
|
+
const sudo = root ? '' : 'sudo ';
|
|
2479
|
+
if (!cliOk.ok) {
|
|
2480
|
+
sendLog('[docker] Chưa có Docker — đang tự cài Docker Engine mới nhất qua script chính thức get.docker.com (1–3 phút)...');
|
|
2481
|
+
await run('sh', ['-c', `curl -fsSL https://get.docker.com -o /tmp/get-docker.sh && ${sudo}sh /tmp/get-docker.sh`]);
|
|
2482
|
+
if (!root) await run('sh', ['-c', 'sudo usermod -aG docker "$USER" || true']).catch(() => {});
|
|
2483
|
+
}
|
|
2484
|
+
sendLog('[docker] Bật & khởi động Docker daemon...');
|
|
2485
|
+
await run('sh', ['-c', `${sudo}systemctl enable --now docker`]).catch(() => {});
|
|
2486
|
+
if (!(await waitForDockerDaemon(20000))) {
|
|
2487
|
+
throw httpError(500, 'Đã cài Docker nhưng daemon chưa chạy. Hãy chạy `systemctl start docker` (hoặc đăng nhập lại nếu vừa thêm vào nhóm docker) rồi cài lại.');
|
|
2488
|
+
}
|
|
2489
|
+
sendLog('[docker] Docker đã sẵn sàng.');
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
if (process.platform === 'darwin') {
|
|
2494
|
+
if (!cliOk.ok) {
|
|
2495
|
+
const brew = await commandExists('brew', ['--version']);
|
|
2496
|
+
if (!brew.ok) {
|
|
2497
|
+
throw httpError(400, 'macOS: cần Homebrew để tự cài Docker Desktop. Cài Homebrew tại https://brew.sh (hoặc cài Docker Desktop thủ công) rồi cài lại.');
|
|
2498
|
+
}
|
|
2499
|
+
sendLog('[docker] macOS: đang cài Docker Desktop mới nhất qua Homebrew (brew install --cask docker)...');
|
|
2500
|
+
await run('brew', ['install', '--cask', 'docker']);
|
|
2501
|
+
}
|
|
2502
|
+
sendLog('[docker] Mở Docker Desktop và chờ daemon khởi động...');
|
|
2503
|
+
await run('open', ['-a', 'Docker']).catch(() => {});
|
|
2504
|
+
if (!(await waitForDockerDaemon(120000))) {
|
|
2505
|
+
throw httpError(500, 'Đã cài Docker Desktop — hãy mở Docker Desktop, hoàn tất cấp quyền lần đầu, đợi biểu tượng cá voi báo "running" rồi cài lại.');
|
|
2506
|
+
}
|
|
2507
|
+
sendLog('[docker] Docker đã sẵn sàng.');
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
if (process.platform === 'win32') {
|
|
2512
|
+
if (!cliOk.ok) {
|
|
2513
|
+
const winget = await commandExists('winget', ['--version']);
|
|
2514
|
+
const choco = await commandExists('choco', ['--version']);
|
|
2515
|
+
if (winget.ok) {
|
|
2516
|
+
sendLog('[docker] Windows: đang cài Docker Desktop mới nhất qua winget...');
|
|
2517
|
+
await run('winget', ['install', '-e', '--id', 'Docker.DockerDesktop', '--accept-source-agreements', '--accept-package-agreements']);
|
|
2518
|
+
} else if (choco.ok) {
|
|
2519
|
+
sendLog('[docker] Windows: đang cài Docker Desktop qua Chocolatey...');
|
|
2520
|
+
await run('choco', ['install', 'docker-desktop', '-y']);
|
|
2521
|
+
} else {
|
|
2522
|
+
throw httpError(400, 'Windows: cần winget hoặc Chocolatey để tự cài Docker Desktop (hoặc cài thủ công tại https://www.docker.com). Cài xong rồi thử lại.');
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
sendLog('[docker] Mở Docker Desktop và chờ daemon khởi động...');
|
|
2526
|
+
await run('cmd', ['/c', 'start', '', '%ProgramFiles%\\Docker\\Docker\\Docker Desktop.exe']).catch(() => {});
|
|
2527
|
+
if (!(await waitForDockerDaemon(120000))) {
|
|
2528
|
+
throw httpError(500, 'Đã cài Docker Desktop — Windows có thể cần bật WSL2 và khởi động lại máy. Hãy mở Docker Desktop, đợi "running" (hoặc reboot nếu được yêu cầu) rồi cài lại.');
|
|
2529
|
+
}
|
|
2530
|
+
sendLog('[docker] Docker đã sẵn sàng.');
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
throw httpError(400, 'Hệ điều hành không được hỗ trợ tự cài Docker. Hãy cài Docker thủ công rồi thử lại.');
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2543
2537
|
async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, routerPort = 20128 }) {
|
|
2544
2538
|
state.installing = true;
|
|
2545
2539
|
state.installed = false;
|
|
@@ -2551,6 +2545,9 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
|
|
|
2551
2545
|
try {
|
|
2552
2546
|
sendLog('OpenClaw local installer started');
|
|
2553
2547
|
sendLog(`Target: OS=${osChoice}, mode=${mode}, project=${projectDir}, gatewayPort=${gatewayPort}, routerPort=${routerPort}`);
|
|
2548
|
+
// Make sure Docker is present (auto-install on Linux/VPS) before doing any work — fail fast
|
|
2549
|
+
// with a clear message rather than deep inside `docker compose up`.
|
|
2550
|
+
await ensureDockerInstalled(osChoice);
|
|
2554
2551
|
await writeCoreProject({ projectDir, osChoice, mode, gatewayPort, routerPort });
|
|
2555
2552
|
await run('npm', ['install', '-g', OPENCLAW_NPM_SPEC]);
|
|
2556
2553
|
await run('npm', ['install', '-g', NINE_ROUTER_NPM_SPEC]);
|
|
@@ -2567,63 +2564,6 @@ async function installCore({ osChoice, mode, projectDir, gatewayPort = 18789, ro
|
|
|
2567
2564
|
await run('docker', ['compose', 'up', '-d', '--build'], { cwd: dockerDir });
|
|
2568
2565
|
await applyResolved9RouterApiKey(projectDir).catch(() => {});
|
|
2569
2566
|
await recreateDockerBot(projectDir).catch(() => {});
|
|
2570
|
-
} else {
|
|
2571
|
-
const nineRouterDataDir = join(projectDir, '.9router');
|
|
2572
|
-
const nineRouterDbPath = join(nineRouterDataDir, 'db', 'data.sqlite');
|
|
2573
|
-
const runtimeEnv = {
|
|
2574
|
-
OPENCLAW_HOME: join(projectDir, '.openclaw'),
|
|
2575
|
-
OPENCLAW_STATE_DIR: join(projectDir, '.openclaw'),
|
|
2576
|
-
DATA_DIR: nineRouterDataDir,
|
|
2577
|
-
OPENCLAW_GATEWAY_PORT: String(gatewayPort),
|
|
2578
|
-
OPENCLAW_PORT: String(gatewayPort),
|
|
2579
|
-
// Parity with the docker runtime env (docker-gen.js) so the gateway and browser plugin
|
|
2580
|
-
// behave the same natively: allow the private-network control-UI websocket and tell the
|
|
2581
|
-
// browser plugin which host OS it is running on.
|
|
2582
|
-
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: '1',
|
|
2583
|
-
OPENCLAW_SETUP_OS: osChoice || '',
|
|
2584
|
-
OPENCLAW_BROWSER_HOST_OS: osChoice || '',
|
|
2585
|
-
};
|
|
2586
|
-
await run('openclaw', ['gateway', 'stop'], { cwd: projectDir, env: runtimeEnv }).catch(() => {});
|
|
2587
|
-
// 9router binds loopback in native mode on purpose: openclaw runs on the SAME host and
|
|
2588
|
-
// reaches it via localhost (see get9RouterBaseUrl), so there is no reason to expose the LLM
|
|
2589
|
-
// proxy on 0.0.0.0 — even on a VPS that would be an open relay. Only the gateway is exposed.
|
|
2590
|
-
// Start 9router first (auto-restart supervised), then sync, then the gateway — so the gateway
|
|
2591
|
-
// boots after 9router is reachable and after the API key has been written to its config.
|
|
2592
|
-
const routerMethod = await startNativeService({
|
|
2593
|
-
projectDir, name: '9router', cmd: '9router', desc: `OpenClaw 9router (${nativeServiceId(projectDir)})`,
|
|
2594
|
-
args: ['-n', '-l', '-H', '127.0.0.1', '-p', String(routerPort), '--skip-update'], env: runtimeEnv,
|
|
2595
|
-
});
|
|
2596
|
-
// Smart-route auto-sync: the docker sidecar runs a script that disables 9router's login
|
|
2597
|
-
// requirement and builds the default `smart-route` combo from the active providers. Native
|
|
2598
|
-
// mode skipped this, so the default model had no backing models. Reuse the exact docker
|
|
2599
|
-
// script with the native DB path (passed via env to stay cross-platform / shell-safe).
|
|
2600
|
-
try {
|
|
2601
|
-
const artifacts = buildDockerArtifacts({ is9Router: true, osChoice, openClawNpmSpec: OPENCLAW_NPM_SPEC, gatewayPort, routerPort });
|
|
2602
|
-
if (artifacts.syncScript) {
|
|
2603
|
-
await fsp.mkdir(nineRouterDataDir, { recursive: true });
|
|
2604
|
-
const syncPath = join(nineRouterDataDir, 'sync.js');
|
|
2605
|
-
await fsp.writeFile(syncPath, artifacts.syncScript, 'utf8');
|
|
2606
|
-
// Run the sync under supervision too (managed service) so it is torn down cleanly on
|
|
2607
|
-
// delete instead of leaking an orphaned `node sync.js` process.
|
|
2608
|
-
await startNativeService({
|
|
2609
|
-
projectDir, name: 'sync', cmd: process.execPath, args: [syncPath],
|
|
2610
|
-
desc: `OpenClaw 9router smart-route sync (${nativeServiceId(projectDir)})`,
|
|
2611
|
-
env: { ...runtimeEnv, NINEROUTER_DB_PATH: nineRouterDbPath, PORT: String(routerPort) },
|
|
2612
|
-
});
|
|
2613
|
-
sendLog('[native] 9router smart-route sync started');
|
|
2614
|
-
}
|
|
2615
|
-
} catch (e) {
|
|
2616
|
-
sendLog(`[native] smart-route sync setup skipped: ${e.message}`);
|
|
2617
|
-
}
|
|
2618
|
-
// Give 9router a moment to boot + sync to disable its login gate, then resolve its API key
|
|
2619
|
-
// into openclaw.json BEFORE the gateway starts (so the gateway reads it on first boot).
|
|
2620
|
-
await new Promise((r) => setTimeout(r, 8000));
|
|
2621
|
-
await applyResolved9RouterApiKey(projectDir).catch(() => {});
|
|
2622
|
-
const gatewayMethod = await startNativeService({
|
|
2623
|
-
projectDir, name: 'gateway', cmd: 'openclaw', desc: `OpenClaw gateway (${nativeServiceId(projectDir)})`,
|
|
2624
|
-
args: ['gateway', 'run'], env: runtimeEnv,
|
|
2625
|
-
});
|
|
2626
|
-
sendLog(`Native runtime started — 9router via ${routerMethod}, gateway via ${gatewayMethod}${state.botPid ? ` (pid=${state.botPid})` : ''}`);
|
|
2627
2567
|
}
|
|
2628
2568
|
state.installed = true;
|
|
2629
2569
|
sendLog('✅ Install completed');
|
|
@@ -3028,10 +2968,6 @@ async function deleteProjectFolder(projectDir, rootProjectDir) {
|
|
|
3028
2968
|
});
|
|
3029
2969
|
// Sleep 2.5 seconds to let Windows file system release overlays/locks
|
|
3030
2970
|
await new Promise((resolve) => setTimeout(resolve, 2500));
|
|
3031
|
-
} else {
|
|
3032
|
-
// Native project: stop & remove any launchd/systemd auto-restart services so they don't keep
|
|
3033
|
-
// respawning a deleted bot. No-op when no services were registered.
|
|
3034
|
-
await removeNativeAutostart(resolved);
|
|
3035
2971
|
}
|
|
3036
2972
|
|
|
3037
2973
|
try {
|
|
@@ -3260,7 +3196,7 @@ async function applyFeatureToggle(projectDir, agentId, kind, id, enabled) {
|
|
|
3260
3196
|
if (dashPort && !composeContent.includes(`:${dashPort}`)) {
|
|
3261
3197
|
const gwPortStr = String(Number(cfg.gateway?.port) || state.gatewayPort || 18789);
|
|
3262
3198
|
composeContent = composeContent.replace(
|
|
3263
|
-
new RegExp(`^(\\s*-\\s*"
|
|
3199
|
+
new RegExp(`^(\\s*-\\s*"[^"\\n]*:${gwPortStr}")\\s*$`, 'm'),
|
|
3264
3200
|
`$1\n - "127.0.0.1:${dashPort}:${dashPort}" # zalo-mod dashboard`
|
|
3265
3201
|
);
|
|
3266
3202
|
await fsp.writeFile(composeFile, composeContent, 'utf8');
|
|
@@ -3459,7 +3395,7 @@ async function installFeature(projectDir, agentId, kind, id) {
|
|
|
3459
3395
|
// Insert dashboard port after the gateway port line
|
|
3460
3396
|
const gwPortStr = String(gwPort);
|
|
3461
3397
|
composeContent = composeContent.replace(
|
|
3462
|
-
new RegExp(`^(\\s*-\\s*"
|
|
3398
|
+
new RegExp(`^(\\s*-\\s*"[^"\\n]*:${gwPortStr}")\\s*$`, 'm'),
|
|
3463
3399
|
`$1\n - ${dashPortMapping} # zalo-mod dashboard`
|
|
3464
3400
|
);
|
|
3465
3401
|
await fsp.writeFile(composeFile, composeContent, 'utf8');
|
|
@@ -3920,6 +3856,9 @@ async function handler(req, res, rootProjectDir) {
|
|
|
3920
3856
|
const projectDir = await resolveProjectDir(rootProjectDir, body);
|
|
3921
3857
|
return json(res, await addBotMount(projectDir, body.hostPath, body.mountName));
|
|
3922
3858
|
}
|
|
3859
|
+
if (url.pathname === '/api/browser/start-chrome-debug' && req.method === 'POST') {
|
|
3860
|
+
return json(res, await startChromeDebug());
|
|
3861
|
+
}
|
|
3923
3862
|
if (url.pathname === '/api/setup/update' && req.method === 'POST') {
|
|
3924
3863
|
const installerDir = resolve(__dirname, '../..');
|
|
3925
3864
|
const isGit = existsSync(resolve(installerDir, '.git'));
|
|
@@ -4036,11 +3975,19 @@ async function handler(req, res, rootProjectDir) {
|
|
|
4036
3975
|
return json(res, { name, content: await fsp.readFile(file, 'utf8') });
|
|
4037
3976
|
}
|
|
4038
3977
|
if (req.method === 'PUT') {
|
|
4039
|
-
|
|
3978
|
+
// Allow the same text types the file tree marks editable (it exposes .json/.js/.yml/…,
|
|
3979
|
+
// not just .md — the old .md-only guard made "Save" silently fail on those files).
|
|
3980
|
+
const writableExt = new Set(['.md', '.txt', '.json', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.yml', '.yaml', '.env', '.sh', '.bat', '.ps1', '.html', '.css']);
|
|
3981
|
+
if (!writableExt.has(extname(name).toLowerCase())) throw httpError(400, `Loại file này không hỗ trợ sửa từ UI (${extname(name) || 'không có đuôi'})`);
|
|
4040
3982
|
const body = await readJson(req);
|
|
4041
3983
|
const projectDir = await resolveProjectDir(rootProjectDir, body);
|
|
4042
3984
|
const file = safeJoin(projectDir, name);
|
|
4043
|
-
|
|
3985
|
+
const content = String(body.content || '');
|
|
3986
|
+
// Don't let a typo brick openclaw.json & friends — reject invalid JSON with a clear error.
|
|
3987
|
+
if (extname(name).toLowerCase() === '.json') {
|
|
3988
|
+
try { JSON.parse(content); } catch (e) { throw httpError(400, `JSON không hợp lệ: ${e.message}`); }
|
|
3989
|
+
}
|
|
3990
|
+
await fsp.writeFile(file, content, 'utf8');
|
|
4044
3991
|
return json(res, { ok: true });
|
|
4045
3992
|
}
|
|
4046
3993
|
}
|
|
@@ -4254,7 +4201,7 @@ export async function startLocalInstaller({ host = '127.0.0.1', preferredPort =
|
|
|
4254
4201
|
printRemoteAccessHint(port).catch(() => {});
|
|
4255
4202
|
}
|
|
4256
4203
|
|
|
4257
|
-
export { createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloUserLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder
|
|
4204
|
+
export { createBotInProject, updateBotInProject, deleteBotInProject, validateOpenclawConfig, startZaloUserLogin, readBotCredentials, resolveProject9RouterApiKey, installCore, deleteProjectFolder };
|
|
4258
4205
|
|
|
4259
4206
|
|
|
4260
4207
|
|
package/dist/web/app.js
CHANGED
|
@@ -7,9 +7,9 @@ const OS_OPTIONS = [
|
|
|
7
7
|
{ id: 'linux-desktop', title: 'Linux Desktop', subtitle: 'Ubuntu / Debian / Fedora', icon: `${SVG_CDN}/linux/default.svg`, badge: 'Desktop' },
|
|
8
8
|
{ id: 'vps', title: 'Linux VPS', subtitle: 'Server install with public bind', icon: `${SVG_CDN}/ubuntu/default.svg`, badge: 'Server' },
|
|
9
9
|
];
|
|
10
|
+
// Docker is the only supported deploy mode. (Native mode was removed.)
|
|
10
11
|
const MODE_OPTIONS = [
|
|
11
12
|
{ id: 'docker', title: 'Docker', subtitle: 'Isolated containers, safest default', icon: `${SVG_CDN}/docker/default.svg`, badge: 'Recommended' },
|
|
12
|
-
{ id: 'native', title: 'Native', subtitle: 'Direct host install, lighter runtime', icon: `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='16' fill='%231b1113'/%3E%3Cpath d='M15 22l9 10-9 10' fill='none' stroke='%23ff3b4d' stroke-width='5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M30 42h19' fill='none' stroke='%23f8f7f7' stroke-width='5' stroke-linecap='round'/%3E%3C/svg%3E`, badge: 'Advanced' },
|
|
13
13
|
];
|
|
14
14
|
const BOT_CHANNELS = [
|
|
15
15
|
{ id: 'telegram', title: 'Telegram', subtitle: 'Bot API', icon: `${SVG_CDN}/telegram/default.svg`, badge: 'Tele' },
|
|
@@ -155,7 +155,7 @@ function fbPluginModal() {
|
|
|
155
155
|
return `<div class="modal-backdrop confirm-backdrop" data-fbplugin="close">
|
|
156
156
|
<section class="donate-modal confirm-modal" role="dialog" aria-modal="true" aria-label="fb-messenger plugin" onclick="event.stopPropagation()">
|
|
157
157
|
<button class="modal-x" data-fbplugin="close" aria-label="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
|
|
158
|
-
<div class="donate-head"><span aria-hidden="true">🔌</span><div><p>${t('Cần plugin','Plugin required')}</p><h2>fb-messenger</h2><small>${t('
|
|
158
|
+
<div class="donate-head"><span aria-hidden="true">🔌</span><div><p>${t('Cần plugin','Plugin required')}</p><h2>fb-messenger</h2><small>${t('Bot <b>Facebook Messenger</b> cần plugin <b>fb-messenger</b> (đã public trên ClawHub). Bấm <b>Tạo bot</b>, rồi mở <b>Bot → Plugins</b> và bấm <b>Cài</b> ở thẻ <b>openclaw-fb-messenger</b>.','A <b>Facebook Messenger</b> bot needs the <b>fb-messenger</b> plugin (now public on ClawHub). Click <b>Create bot</b>, then open <b>Bot → Plugins</b> and hit <b>Install</b> on the <b>openclaw-fb-messenger</b> card.')}</small></div></div>
|
|
159
159
|
<div class="socials" style="justify-content:center;display:flex;gap:10px;width:100%;margin:8px 0 4px;">
|
|
160
160
|
${socials.map(([n,u])=>`<a href="${u}" target="_blank" rel="noopener" aria-label="${n}">${socialIcon(n)}</a>`).join('')}
|
|
161
161
|
</div>
|
|
@@ -169,8 +169,9 @@ function confirmModal() {
|
|
|
169
169
|
return `<div class="modal-backdrop confirm-backdrop" data-confirm-action="cancel">
|
|
170
170
|
<section class="donate-modal confirm-modal" role="dialog" aria-modal="true" aria-label="${escapeHtml(m.title)}" onclick="event.stopPropagation()">
|
|
171
171
|
<button class="modal-x" data-confirm-action="cancel" aria-label="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
|
|
172
|
-
<div class="donate-head"><span aria-hidden="true"
|
|
173
|
-
|
|
172
|
+
<div class="donate-head"><span aria-hidden="true">${escapeHtml(m.icon || '⚠')}</span><div><p>${escapeHtml(m.eyebrow || t('Xác nhận','Confirm'))}</p><h2>${escapeHtml(m.title)}</h2><small>${escapeHtml(m.message || '')}</small></div></div>
|
|
173
|
+
${m.bodyHtml ? `<div class="confirm-body">${m.bodyHtml}</div>` : ''}
|
|
174
|
+
<div class="confirm-actions">${m.hideCancel ? '' : `<button class="secondary" data-confirm-action="cancel">${t('Hủy','Cancel')}</button>`}<button class="primary ${m.okDanger === false ? '' : 'danger'}" data-confirm-action="ok">${escapeHtml(m.okText || t('Xóa','Delete'))}</button></div>
|
|
174
175
|
</section>
|
|
175
176
|
</div>`;
|
|
176
177
|
}
|
|
@@ -210,7 +211,6 @@ function installModal() {
|
|
|
210
211
|
const osChoices = OS_OPTIONS.map(o => [o.id, t(o.title, o.title), trChoice(o).subtitle]);
|
|
211
212
|
const modeChoices = [
|
|
212
213
|
['docker', 'Docker', t('\u0043ontainer c\u00f4 l\u1eadp, an to\u00e0n nh\u1ea5t', 'Isolated containers, safest default')],
|
|
213
|
-
['native', 'Native', t('C\u00e0i tr\u1ef1c ti\u1ebfp, runtime nh\u1eb9 h\u01a1n', 'Direct host install, lighter runtime')],
|
|
214
214
|
];
|
|
215
215
|
return `<div class="modal-backdrop install-backdrop" data-install-modal="close">
|
|
216
216
|
<section class="donate-modal install-modal" role="dialog" aria-modal="true" aria-label="${t('T\u1ea1o Project','Create Project')}" onclick="event.stopPropagation()">
|
|
@@ -519,7 +519,7 @@ function renderFilesPanel() {
|
|
|
519
519
|
// Re-wire only file-panel-specific handlers
|
|
520
520
|
panel.querySelectorAll('[data-select-file]').forEach(btn => btn.onclick = () => { state.selectedFile = btn.dataset.selectFile; renderFilesPanel(); });
|
|
521
521
|
panel.querySelectorAll('[data-toggle-dir]').forEach(btn => btn.onclick = () => { const p = btn.dataset.toggleDir; state.openDirs[p] = !(state.openDirs[p] ?? true); renderFilesPanel(); });
|
|
522
|
-
panel.querySelectorAll('.save').forEach(btn => btn.onclick = () => withButtonLoading(btn, async () => { const name = btn.dataset.file; await api('/api/bot/files/'+encodeURIComponent(name), { method: 'PUT', body: { projectDir: activeProjectDir(), content: document.querySelector(`[data-editor="${CSS.escape(name)}"]`).value } }); showToast(t('Đã lưu', 'Saved'), t('Đã lưu tệp tin: ', 'Saved file: ') + fileBaseName(name), 'success'); btn.innerHTML=`${actionIcon('save')} ${ui('saved')}`; setTimeout(()=>btn.innerHTML=`${actionIcon('save')} ${ui('save')}`,1200); }));
|
|
522
|
+
panel.querySelectorAll('.save').forEach(btn => btn.onclick = () => withButtonLoading(btn, async () => { const name = btn.dataset.file; try { await api('/api/bot/files/'+encodeURIComponent(name), { method: 'PUT', body: { projectDir: activeProjectDir(), content: document.querySelector(`[data-editor="${CSS.escape(name)}"]`).value } }); showToast(t('Đã lưu', 'Saved'), t('Đã lưu tệp tin: ', 'Saved file: ') + fileBaseName(name), 'success'); btn.innerHTML=`${actionIcon('save')} ${ui('saved')}`; setTimeout(()=>btn.innerHTML=`${actionIcon('save')} ${ui('save')}`,1200); } catch (err) { showToast(t('Lưu thất bại', 'Save failed'), err.message, 'error'); } }));
|
|
523
523
|
}
|
|
524
524
|
|
|
525
525
|
function renderSkillsPanel() {
|
|
@@ -564,6 +564,34 @@ function wireSkillsHandlers(scope = document) {
|
|
|
564
564
|
}
|
|
565
565
|
renderSkillsPanel();
|
|
566
566
|
});
|
|
567
|
+
scope.querySelectorAll('[data-chrome-debug]').forEach(btn => btn.onclick = async () => {
|
|
568
|
+
btn.disabled = true;
|
|
569
|
+
try {
|
|
570
|
+
const r = await api('/api/browser/start-chrome-debug', { method: 'POST', body: {} });
|
|
571
|
+
if (r.headless) {
|
|
572
|
+
// VPS has no display — guide the user to run Chrome on THEIR machine + a reverse tunnel.
|
|
573
|
+
state.confirmModal = {
|
|
574
|
+
icon: '🌐',
|
|
575
|
+
eyebrow: t('Chrome debug', 'Chrome debug'),
|
|
576
|
+
title: t('Dùng Chrome trên máy của bạn', 'Use Chrome on your computer'),
|
|
577
|
+
message: t('VPS không có màn hình nên không mở Chrome tại đây. Chạy 2 lệnh dưới trên máy của bạn và GIỮ chúng chạy — bot sẽ tự dùng Chrome đó (cổng 9222). Không làm cũng không sao: bot vẫn có trình duyệt ẩn (headless) sẵn trong container.', 'This VPS has no display. Run the two commands below on YOUR computer and keep them running — the bot will use that Chrome (port 9222). Optional: the bot already has a built-in headless browser.'),
|
|
578
|
+
bodyHtml: `<p class="guide-step">1. ${t('Mở Chrome debug trên máy bạn — macOS:', 'Start Chrome debug on your machine — macOS:')}</p><code class="cmd">${escapeHtml(r.chromeCmdMac)}</code><p class="guide-step">${t('Hoặc Windows:', 'Or Windows:')}</p><code class="cmd">${escapeHtml(r.chromeCmdWin)}</code><p class="guide-step">2. ${t('Tạo tunnel về VPS (cửa sổ terminal khác):', 'Create the reverse tunnel (another terminal):')}</p><code class="cmd">${escapeHtml(r.tunnelCmd)}</code>`,
|
|
579
|
+
okText: t('Đã hiểu', 'Got it'),
|
|
580
|
+
okDanger: false,
|
|
581
|
+
hideCancel: true,
|
|
582
|
+
onConfirm: () => { state.confirmModal = null; render(); },
|
|
583
|
+
};
|
|
584
|
+
render();
|
|
585
|
+
} else {
|
|
586
|
+
showToast(t('Đã mở Chrome debug', 'Chrome debug started'),
|
|
587
|
+
`${t('Cổng', 'Port')} ${r.port} — ${t('bot sẽ ưu tiên dùng Chrome này', 'the bot will prefer this Chrome')}`, 'success');
|
|
588
|
+
}
|
|
589
|
+
} catch (err) {
|
|
590
|
+
showToast(t('Thất bại', 'Failed'), err.message, 'error');
|
|
591
|
+
} finally {
|
|
592
|
+
btn.disabled = false;
|
|
593
|
+
}
|
|
594
|
+
});
|
|
567
595
|
}
|
|
568
596
|
|
|
569
597
|
function title() {
|
|
@@ -907,11 +935,17 @@ function joinDisplayPath(root = '', child = '') {
|
|
|
907
935
|
const r = String(root || '').replace(/[\\/]+$/, '');
|
|
908
936
|
const c = String(child || '').replace(/^[\\/]+/, '');
|
|
909
937
|
if (!r) return c || '-';
|
|
910
|
-
|
|
938
|
+
if (!c) return r;
|
|
939
|
+
// Match the separator style of the project root: Windows paths (C:\...) join with "\",
|
|
940
|
+
// POSIX paths join with "/" (the old always-backslash join rendered Linux paths mangled).
|
|
941
|
+
const win = /^[A-Za-z]:/.test(r) || r.includes('\\');
|
|
942
|
+
return win ? `${r}\\${c.replace(/\//g, '\\')}` : `${r}/${c.replace(/\\/g, '/')}`;
|
|
911
943
|
}
|
|
912
944
|
function projectPathLine(bot = currentBot(), fileName = '') {
|
|
913
945
|
const s = state.install || {};
|
|
914
|
-
|
|
946
|
+
// Legacy configs stored the container-absolute workspace; show it relative to the project root
|
|
947
|
+
// (new configs are already relative — see bot-config-gen).
|
|
948
|
+
const workspace = String(bot?.workspace || '').replace(/^\/home\/node\/project\/?/, '');
|
|
915
949
|
const relFile = fileName ? String(fileName).replace(/^\.openclaw[\\/][^\\/]+[\\/]?/, '') : '';
|
|
916
950
|
const full = workspace ? joinDisplayPath(s.projectDir || '', relFile ? `${workspace}/${relFile}` : workspace) : (s.projectDir || '-');
|
|
917
951
|
const label = workspace ? t('Workspace','Workspace') : ui('project');
|
|
@@ -972,6 +1006,7 @@ function botSkillsPanel() {
|
|
|
972
1006
|
{ id: 'memory-tencentdb', title: 'TencentDB Agent Memory', desc: 'Bộ nhớ phân tầng L0–L3 + nén ngữ cảnh: nhớ tốt session dài, tiết kiệm ~61% token (local SQLite, không cần API)' },
|
|
973
1007
|
{ id: 'openclaw-browser-automation', title: 'openclaw-browser-automation', desc: 'Smart Search + Browser (headless & Chrome thật)' },
|
|
974
1008
|
{ id: 'openclaw-zalo-mod', title: 'openclaw-zalo-mod', desc: 'Zalo group helpers', channels: ['zalo-personal'], openWebPort: 18790, openWebPath: '/dashboard' },
|
|
1009
|
+
{ id: 'openclaw-fb-messenger', title: 'openclaw-fb-messenger', desc: t('Kênh Facebook Messenger — webhook + Graph API (bắt buộc cho bot Messenger)', 'Facebook Messenger channel — webhook + Graph API (required for Messenger bots)'), channels: ['fb-messenger'] },
|
|
975
1010
|
{ id: 'openclaw-facebook-crawler', title: 'openclaw-facebook-crawler', desc: 'Facebook crawler automation', channels: ['fb-messenger'] },
|
|
976
1011
|
{ id: 'openclaw-n8n-facebook-poster', title: 'openclaw-n8n-facebook-poster', desc: 'Facebook post automation (n8n)', channels: ['fb-messenger'] },
|
|
977
1012
|
];
|
|
@@ -1003,6 +1038,9 @@ function botSkillsPanel() {
|
|
|
1003
1038
|
if (requiresInstall) {
|
|
1004
1039
|
secs.push(`<button class="secondary icon-btn2 update-plugin-btn" type="button" data-feature-install="${key}" ${loading ? 'disabled' : ''} title="${t('Cập nhật lên bản mới nhất','Update to latest version')}" style="padding: 4px 8px; font-size: 11px; height: 28px; border-width: 1px; color:#ffb020; border-color: rgba(255,176,32,0.25); background: rgba(255,176,32,0.05);">${actionIcon('refresh')}<span>${t('Cập nhật','Update')}</span></button>`);
|
|
1005
1040
|
}
|
|
1041
|
+
if (item.id === 'browser-automation' || item.id === 'openclaw-browser-automation') {
|
|
1042
|
+
secs.push(`<button class="secondary icon-btn2" type="button" data-chrome-debug title="${t('Mở Chrome (chế độ debug) trên máy này để bot dùng trình duyệt thật (đã đăng nhập)','Open real Chrome in debug mode on this machine so the bot can use it')}" style="padding: 4px 8px; font-size: 11px; height: 28px; border-width: 1px; color:#4285F4; border-color: rgba(66,133,244,0.3); background: rgba(66,133,244,0.06);">${actionIcon('link')}<span>${t('Mở Chrome debug','Open Chrome debug')}</span></button>`);
|
|
1043
|
+
}
|
|
1006
1044
|
} else {
|
|
1007
1045
|
secs.push(`<button class="secondary icon-btn2" type="button" data-feature-install="${key}" ${loading ? 'disabled' : ''}>${actionIcon('download')} ${ui('installVerb')}</button>`);
|
|
1008
1046
|
}
|
|
@@ -1371,13 +1409,9 @@ document.querySelectorAll('[data-project-pick-folder]').forEach(btn => btn.oncli
|
|
|
1371
1409
|
});
|
|
1372
1410
|
$('#bot-create')?.addEventListener('submit', async (ev) => {
|
|
1373
1411
|
ev.preventDefault();
|
|
1374
|
-
// Facebook Messenger needs the
|
|
1375
|
-
//
|
|
1376
|
-
|
|
1377
|
-
state.fbPluginModalOpen = true;
|
|
1378
|
-
render();
|
|
1379
|
-
return;
|
|
1380
|
-
}
|
|
1412
|
+
// Facebook Messenger needs the fb-messenger plugin (public on ClawHub). We don't block
|
|
1413
|
+
// creation — the heads-up modal on channel-select tells the user to install it from the
|
|
1414
|
+
// bot's Plugins section afterwards (installable via the openclaw-fb-messenger card).
|
|
1381
1415
|
const submitBtn = ev.currentTarget.querySelector('button[type="submit"]');
|
|
1382
1416
|
if (submitBtn?.classList.contains('is-loading')) return;
|
|
1383
1417
|
await withButtonLoading(submitBtn, async () => {
|
|
@@ -1428,7 +1462,7 @@ document.querySelectorAll('[data-project-pick-folder]').forEach(btn => btn.oncli
|
|
|
1428
1462
|
}
|
|
1429
1463
|
});
|
|
1430
1464
|
});
|
|
1431
|
-
document.querySelectorAll('.save').forEach(btn => btn.onclick = () => withButtonLoading(btn, async () => { const name = btn.dataset.file; await api('/api/bot/files/'+encodeURIComponent(name), { method: 'PUT', body: { projectDir: activeProjectDir(), content: document.querySelector(`[data-editor="${CSS.escape(name)}"]`).value } }); showToast(t('Đã lưu', 'Saved'), t('Đã lưu tệp tin: ', 'Saved file: ') + fileBaseName(name), 'success'); btn.innerHTML=`${actionIcon('save')} ${ui('saved')}`; setTimeout(()=>btn.innerHTML=`${actionIcon('save')} ${ui('save')}`,1200); }));
|
|
1465
|
+
document.querySelectorAll('.save').forEach(btn => btn.onclick = () => withButtonLoading(btn, async () => { const name = btn.dataset.file; try { await api('/api/bot/files/'+encodeURIComponent(name), { method: 'PUT', body: { projectDir: activeProjectDir(), content: document.querySelector(`[data-editor="${CSS.escape(name)}"]`).value } }); showToast(t('Đã lưu', 'Saved'), t('Đã lưu tệp tin: ', 'Saved file: ') + fileBaseName(name), 'success'); btn.innerHTML=`${actionIcon('save')} ${ui('saved')}`; setTimeout(()=>btn.innerHTML=`${actionIcon('save')} ${ui('save')}`,1200); } catch (err) { showToast(t('Lưu thất bại', 'Save failed'), err.message, 'error'); } }));
|
|
1432
1466
|
wireSkillsHandlers(document);
|
|
1433
1467
|
}
|
|
1434
1468
|
|
package/dist/web/styles.css
CHANGED
|
@@ -419,7 +419,6 @@ html[data-theme="light"] .bot-form-grid input,html[data-theme="light"] .bot-form
|
|
|
419
419
|
.mini-pill{display:inline-flex;align-items:center;min-height:26px;padding:4px 10px;border-radius:999px;background:rgba(255,255,255,.04);border:1px solid rgba(255,36,54,.55);font-size:12px;font-weight:800;letter-spacing:.2px}
|
|
420
420
|
.mini-pill--os{border-color:#64748b;color:#cbd5e1}
|
|
421
421
|
.mini-pill--docker{border-color:#2496ed;color:#9bd5ff}
|
|
422
|
-
.mini-pill--native{border-color:#7c5cff;color:#cfc4ff}
|
|
423
422
|
.channel-tabs{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:18px}
|
|
424
423
|
.channel-tabs button{display:inline-flex;align-items:center;gap:8px;border:1px solid rgba(255,36,54,.24);background:rgba(255,255,255,.045);color:var(--body);border-radius:999px;padding:9px 12px;font-weight:900;cursor:pointer}
|
|
425
424
|
.channel-tabs button.active{background:rgba(255,36,54,.16);border-color:var(--primary);color:var(--ink);box-shadow:0 0 0 1px rgba(255,36,54,.18) inset}
|
|
@@ -1931,6 +1930,12 @@ body:has(.modal-backdrop) .bottom{display:none!important}
|
|
|
1931
1930
|
/* Bot / Cài đặt hero buttons sit side-by-side — keep their labels on one line. */
|
|
1932
1931
|
.dash-actions .icon-btn2{white-space:nowrap}
|
|
1933
1932
|
|
|
1933
|
+
/* [Confirm modal] Optional body block for step-by-step guides (e.g. Chrome-debug on a VPS):
|
|
1934
|
+
copyable command lines with wrap, compact spacing. */
|
|
1935
|
+
.confirm-body{display:grid;gap:8px;margin:14px 0 2px}
|
|
1936
|
+
.confirm-body .cmd{margin-top:0;user-select:all;word-break:break-all;white-space:pre-wrap;font-size:11.5px;line-height:1.5}
|
|
1937
|
+
.guide-step{margin:4px 0 0;color:var(--body);font-size:13px;font-weight:700}
|
|
1938
|
+
|
|
1934
1939
|
@media(max-width:760px){
|
|
1935
1940
|
/* [Topbar] Keep search + theme + language on ONE row; theme becomes icon-only and the
|
|
1936
1941
|
language globe icon is dropped so the whole bar stays compact. */
|