chore: add 105 runtime and openhands ops tooling
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
# g2.tkmind.cn → 301 跳转到 m.tkmind.cn(保留微信 webhook 直至后台改完服务器 URL)
|
||||
# 安装:/etc/nginx/conf.d/g2.tkmind.cn.conf
|
||||
# 不再依赖 19081 reverse tunnel / 10.10.* 内网地址,直接回源到 58.38.22.103:8081
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
@@ -20,7 +21,7 @@ server {
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
location = /webhooks/wechat-mp/messages {
|
||||
proxy_pass http://127.0.0.1:19081;
|
||||
proxy_pass http://58.38.22.103:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host g2.tkmind.cn;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# m.tkmind.cn → 103 Memind Portal (:8081)
|
||||
# m.tkmind.cn → 105 nginx 直连 103 Memind Portal (58.38.22.103:8081)
|
||||
# 安装:/etc/nginx/conf.d/m.tkmind.cn.conf
|
||||
# 替换旧 ai_mind 配置,作为 Memind H5 正式公网入口
|
||||
# 不再依赖 19081 reverse tunnel / 10.10.* 内网地址,直接回源到 58.38.22.103:8081
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
@@ -23,7 +24,7 @@ server {
|
||||
client_max_body_size 100M;
|
||||
|
||||
location = /webhooks/wechat-mp/messages {
|
||||
proxy_pass http://127.0.0.1:19081;
|
||||
proxy_pass http://58.38.22.103:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host m.tkmind.cn;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -40,7 +41,7 @@ server {
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:19081;
|
||||
proxy_pass http://58.38.22.103:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# wechat.m.tkmind.cn → 微信服务号消息推送专用域名
|
||||
# 安装:/etc/nginx/conf.d/wechat.m.tkmind.cn.conf
|
||||
# 不再依赖 19081 reverse tunnel / 10.10.* 内网地址,直接回源到 58.38.22.103:8081
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
@@ -20,7 +21,7 @@ server {
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
location = /webhooks/wechat-mp/messages {
|
||||
proxy_pass http://127.0.0.1:19081;
|
||||
proxy_pass http://58.38.22.103:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host m.tkmind.cn;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: "${OPENHANDS_DB_NAME:-openhands}"
|
||||
POSTGRES_USER: "${OPENHANDS_DB_USER:-openhands}"
|
||||
POSTGRES_PASSWORD: "${OPENHANDS_DB_PASS:-openhands_dev_password}"
|
||||
volumes:
|
||||
- openhands_pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- pg_isready -U "${OPENHANDS_DB_USER:-openhands}" -d "${OPENHANDS_DB_NAME:-openhands}"
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
openhands:
|
||||
build:
|
||||
context: ./docker/openhands-colima
|
||||
dockerfile: Dockerfile
|
||||
image: memind/openhands-colima:local
|
||||
restart: unless-stopped
|
||||
stdin_open: true
|
||||
tty: true
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
entrypoint:
|
||||
- /bin/bash
|
||||
- -lc
|
||||
- >
|
||||
mkdir -p /home/enduser/.openhands &&
|
||||
chown -R ${OPENHANDS_SANDBOX_UID:-501}:0 /home/enduser/.openhands &&
|
||||
exec /app/entrypoint.sh /app/.venv/bin/uvicorn openhands.server.listen:app --host 0.0.0.0 --port 3000
|
||||
ports:
|
||||
- "${OPENHANDS_PORT:-3000}:3000"
|
||||
environment:
|
||||
LOG_ALL_EVENTS: "${OPENHANDS_LOG_ALL_EVENTS:-true}"
|
||||
SANDBOX_USER_ID: "${OPENHANDS_SANDBOX_UID:-501}"
|
||||
SANDBOX_VOLUMES: "${OPENHANDS_WORKSPACE:-/Users/john/Project/Memind}:/workspace:rw"
|
||||
OH_PERSISTENCE_DIR: "/home/enduser/.openhands"
|
||||
DB_HOST: "postgres"
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: "${OPENHANDS_DB_NAME:-openhands}"
|
||||
DB_USER: "${OPENHANDS_DB_USER:-openhands}"
|
||||
DB_PASS: "${OPENHANDS_DB_PASS:-openhands_dev_password}"
|
||||
volumes:
|
||||
- "${OPENHANDS_DOCKER_SOCK:-/Users/john/.colima/default/docker.sock}:/var/run/docker.sock"
|
||||
- openhands_state:/home/enduser/.openhands
|
||||
- "${OPENHANDS_WORKSPACE:-/Users/john/Project/Memind}:/workspace:rw"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
volumes:
|
||||
openhands_state:
|
||||
openhands_pg_data:
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Sync DB with Models
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2025-10-05 11:28:41.772294
|
||||
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '002'
|
||||
down_revision: str | None = '001'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
class EventCallbackStatus(Enum):
|
||||
ACTIVE = 'ACTIVE'
|
||||
DISABLED = 'DISABLED'
|
||||
COMPLETED = 'COMPLETED'
|
||||
ERROR = 'ERROR'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
bind = op.get_bind()
|
||||
event_callback_status = sa.Enum(
|
||||
EventCallbackStatus,
|
||||
name='eventcallbackstatus',
|
||||
)
|
||||
event_callback_status.create(bind, checkfirst=True)
|
||||
|
||||
op.add_column(
|
||||
'event_callback',
|
||||
sa.Column(
|
||||
'status',
|
||||
event_callback_status,
|
||||
nullable=False,
|
||||
server_default='ACTIVE',
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'event_callback',
|
||||
sa.Column(
|
||||
'updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
)
|
||||
op.drop_index('ix_event_callback_result_event_id')
|
||||
op.drop_column('event_callback_result', 'event_id')
|
||||
op.add_column(
|
||||
'event_callback_result', sa.Column('event_id', sa.String, nullable=True)
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_event_callback_result_event_id'),
|
||||
'event_callback_result',
|
||||
['event_id'],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
bind = op.get_bind()
|
||||
event_callback_status = sa.Enum(
|
||||
EventCallbackStatus,
|
||||
name='eventcallbackstatus',
|
||||
)
|
||||
|
||||
op.drop_column('event_callback', 'status')
|
||||
op.drop_column('event_callback', 'updated_at')
|
||||
op.drop_index('ix_event_callback_result_event_id')
|
||||
op.drop_column('event_callback_result', 'event_id')
|
||||
op.add_column(
|
||||
'event_callback_result', sa.Column('event_id', sa.UUID, nullable=True)
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_event_callback_result_event_id'),
|
||||
'event_callback_result',
|
||||
['event_id'],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
event_callback_status.drop(bind, checkfirst=True)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add composite index on event_callback for execute_callbacks query
|
||||
|
||||
Revision ID: 010
|
||||
Revises: 009
|
||||
Create Date: 2026-06-03
|
||||
"""
|
||||
|
||||
from typing import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = '010'
|
||||
down_revision: str | None = '009'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Local PG validation uses an empty dev database, so a regular index keeps
|
||||
# startup reliable without depending on autocommit behavior.
|
||||
op.create_index(
|
||||
'ix_event_callback_conversation_id_status_event_kind',
|
||||
'event_callback',
|
||||
['conversation_id', 'status', 'event_kind'],
|
||||
if_not_exists=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
'ix_event_callback_conversation_id_status_event_kind',
|
||||
table_name='event_callback',
|
||||
if_exists=True,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM ghcr.io/openhands/openhands:latest
|
||||
|
||||
RUN /app/.venv/bin/pip install --no-cache-dir asyncpg
|
||||
|
||||
COPY 002.py /app/openhands/app_server/app_lifespan/alembic/versions/002.py
|
||||
COPY 010.py /app/openhands/app_server/app_lifespan/alembic/versions/010.py
|
||||
@@ -56,6 +56,64 @@
|
||||
|
||||
**不要**使用已停用的 `rsync_to_server.sh` 或直接 rsync 到 `105` 作为常规发布手段(见 `PRODUCTION_RELEASE_RULES.md`)。
|
||||
|
||||
## 105 Portal runtime 发布
|
||||
|
||||
`105` 上当前 H5 由 `systemd` 服务 `goose-h5` 运行,真实目录是:
|
||||
|
||||
```text
|
||||
/root/tkmind_go/ui/h5
|
||||
```
|
||||
|
||||
发布 `105` 的 Portal / 微信服务号相关能力时,必须走 **runtime artifact**,不要直接发源码树:
|
||||
|
||||
```bash
|
||||
cd /Users/john/Project/Memind
|
||||
|
||||
# 仅预演,不切换 live
|
||||
bash scripts/release-portal-runtime-105.sh --dry-run
|
||||
|
||||
# 正式发布到 105
|
||||
bash scripts/release-portal-runtime-105.sh --yes
|
||||
```
|
||||
|
||||
### 这条发布链的关键规则
|
||||
|
||||
1. 本地先把 `server.mjs` 与 `mindspace-sandbox-mcp.mjs` 打成 runtime bundle,再连同 `dist/`、`public/`、`schema.sql`、启动脚本一起打包。
|
||||
2. **不要**把本机 Mac 的 `node_modules` 直接打进包发到 `105`。
|
||||
`105` 是 Linux,像 `@node-rs/argon2` 这类原生依赖会因为平台不匹配而启动失败。
|
||||
3. `105` 的 runtime 包默认**不带** `node_modules`;切换前会在 `105` 本机执行:
|
||||
|
||||
```bash
|
||||
npm install --omit=dev --no-package-lock
|
||||
```
|
||||
|
||||
确保安装的是 Linux 可用依赖。
|
||||
4. 发布时默认保留这些持久项,不覆盖:
|
||||
- `.env`
|
||||
- `MindSpace/`
|
||||
- `data/`
|
||||
- `users/`
|
||||
- `logs/`
|
||||
- `public/plaza-covers/`
|
||||
5. 发布完成后必须验:
|
||||
|
||||
```bash
|
||||
ssh root@120.26.184.105 '
|
||||
systemctl is-active goose-h5
|
||||
curl -sf http://127.0.0.1:8080/api/status
|
||||
'
|
||||
```
|
||||
|
||||
正确结果应为:
|
||||
- `goose-h5` = `active`
|
||||
- `/api/status` 返回 `ok`
|
||||
|
||||
### 禁止事项(105 runtime)
|
||||
|
||||
- 禁止把 `node_modules/` 从本机打包后直接发到 `105`
|
||||
- 禁止在 `105` 直接改 `server.mjs` / `wechat-mp.mjs` / `scripts/wechat-mp-menu.mjs`
|
||||
- 禁止跳过健康检查就宣称“已发布”
|
||||
|
||||
## 服务号底部菜单(`wechat-mp-menu.mjs`)
|
||||
|
||||
菜单名称与链接定义在本地:
|
||||
|
||||
@@ -22,6 +22,28 @@ pnpm dev
|
||||
pnpm open:local-test # 浏览器打开 H5
|
||||
```
|
||||
|
||||
### LaunchAgent 常驻(推荐本机联调)
|
||||
|
||||
终端里跑 `pnpm dev` 会在 Cursor / 终端关闭时被 SIGTERM 停掉。若希望登录后自动拉起、掉线自恢复,可安装 LaunchAgent(与 imgproxy / goosed 同类):
|
||||
|
||||
```bash
|
||||
pnpm setup:dev-launchagent
|
||||
# 卸载
|
||||
pnpm setup:dev-launchagent:uninstall
|
||||
```
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| Label | `cn.tkmind.memind-dev` |
|
||||
| 进程 | portal (8081) + memind_adm (8082) + Ops (3002) + Vite (5173) |
|
||||
| 日志 | `~/Library/Logs/memind-dev.log` |
|
||||
|
||||
安装后仍可用 `pnpm dev` 做前台调试;两者不要同时占用同一端口。重启服务:
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/cn.tkmind.memind-dev
|
||||
```
|
||||
|
||||
需要旧式全栈联动时,改用:
|
||||
|
||||
```bash
|
||||
|
||||
+85
-11
@@ -1,18 +1,22 @@
|
||||
# OpenHands 安装说明
|
||||
|
||||
> 目标:先把 OpenHands 独立安装并跑起来,确认可用后,再接入 `memindadm` 的 Goose 网关策略中心。
|
||||
> 目标:先把 OpenHands 独立安装并跑起来,确认可用后,再接入 Goose / 路由策略。
|
||||
>
|
||||
> 原则:安装阶段不改动现有 Goose 服务,不影响当前生产/测试链路。
|
||||
|
||||
## 1. 推荐方案
|
||||
|
||||
如果你的目标是后续和 Goose 做集成,建议先用 **OpenHands CLI + GUI Server** 方式启动:
|
||||
如果你的目标是后续和 Goose 做集成,要先分清两条链:
|
||||
|
||||
- 本地直接运行
|
||||
- 可挂载当前仓库目录
|
||||
- 便于后续做执行器接入验证
|
||||
- `OpenHands Web / GUI`:浏览器访问的服务,适合人工打开页面验证
|
||||
- `Goose -> OpenHands`:Goose 直接拉起 `OpenHands CLI` 进程做委托执行,不走 `127.0.0.1:3000` 这个网页入口
|
||||
|
||||
官方文档对 `openhands serve` 的说明是:它会通过 Docker 启动本地 GUI Server,支持挂载当前目录,适合直接对仓库做任务。
|
||||
本仓库当前建议:
|
||||
|
||||
- 页面服务用 `Colima + docker compose`
|
||||
- Goose 委托执行继续用本机 `OpenHands CLI`
|
||||
|
||||
这样边界最清楚,也不会把 Goose 和 OpenHands 强耦合到一个镜像里。
|
||||
|
||||
## 2. 前置条件
|
||||
|
||||
@@ -73,6 +77,44 @@ openhands serve --mount-cwd
|
||||
|
||||
如果你更偏向容器化,可按 OpenHands 的 Agent Canvas / Docker 文档走容器启动方式。这个方式适合未来把 OpenHands 作为更独立的执行环境来跑。
|
||||
|
||||
### 方式 D:本仓库 Colima compose
|
||||
|
||||
如果本机 Docker 实际跑在 Colima,而不是 Docker Desktop 默认 socket,可以直接使用仓库内置的 compose 文件:
|
||||
|
||||
```bash
|
||||
cd /Users/john/Project/Memind
|
||||
bash scripts/openhands-colima.sh pull
|
||||
bash scripts/openhands-colima.sh build
|
||||
bash scripts/openhands-colima.sh up
|
||||
```
|
||||
|
||||
默认行为:
|
||||
|
||||
- 启动 `postgres:16-alpine`
|
||||
- 启动本地 overlay 镜像 `memind/openhands-colima:local`
|
||||
- 挂载 Colima socket:`/Users/john/.colima/default/docker.sock`
|
||||
- 挂载当前仓库到容器内:`/workspace`
|
||||
- 对外暴露 GUI 端口:`3000`
|
||||
- OpenHands 持久化走本地 Postgres,而不是 SQLite
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
bash scripts/openhands-colima.sh build
|
||||
bash scripts/openhands-colima.sh status
|
||||
bash scripts/openhands-colima.sh logs
|
||||
bash scripts/openhands-colima.sh restart
|
||||
bash scripts/openhands-colima.sh down
|
||||
```
|
||||
|
||||
当前 PG 默认值:
|
||||
|
||||
- `OPENHANDS_DB_NAME=openhands`
|
||||
- `OPENHANDS_DB_USER=openhands`
|
||||
- `OPENHANDS_DB_PASS=openhands_dev_password`
|
||||
|
||||
如果需要自定义,可在执行脚本前导出环境变量。
|
||||
|
||||
### 端口与资源
|
||||
|
||||
- GUI Server 默认会占用 `3000` 端口
|
||||
@@ -133,11 +175,28 @@ openhands serve --mount-cwd
|
||||
启动成功后,确认以下几点:
|
||||
|
||||
1. 浏览器能打开 OpenHands 的界面。
|
||||
2. 可以创建一次简单任务。
|
||||
3. 如果用了 `--mount-cwd`,任务可以看到当前仓库。
|
||||
2. `curl http://127.0.0.1:3000` 返回 `200 OK`。
|
||||
3. Postgres 中可以看到 OpenHands 表,例如 `event_callback`、`conversation_metadata`。
|
||||
4. Docker 不报权限或 socket 错误。
|
||||
|
||||
## 7. 用于后续 Goose 集成时的建议
|
||||
## 7. Goose 集成边界
|
||||
|
||||
当前 Goose 不是调用 `http://127.0.0.1:3000` 这个 GUI 服务,而是直接执行本机 `OpenHands CLI`:
|
||||
|
||||
- `GOOSE_OPENHANDS_BIN=/Users/john/.openhands/bin/studio-openhands`
|
||||
- `GOOSE_OPENHANDS_RUNNER=host`
|
||||
|
||||
其中:
|
||||
|
||||
- `/Users/john/.openhands/bin/studio-openhands` 目前只是一个 wrapper
|
||||
- 实际执行的是 `/Users/john/.local/bin/openhands`
|
||||
|
||||
这意味着:
|
||||
|
||||
- `Colima` 里的 OpenHands Web 服务,主要用于人手打开页面和验证 PG 持久化
|
||||
- Goose 的 OpenHands 委托执行,主要取决于本机 CLI 是否可用、模型环境变量是否齐全
|
||||
|
||||
## 8. 用于后续 Goose 集成时的建议
|
||||
|
||||
为了后面接入 `memindadm`,建议你先准备好这些信息:
|
||||
|
||||
@@ -152,7 +211,7 @@ openhands serve --mount-cwd
|
||||
|
||||
如果你后面已经决定把 `Goose / Aider / OpenHands` 的模型统一收口到 `memindadm`,那么 OpenHands 这边也不要再单独维护自己的模型配置,统一从后台读取即可。
|
||||
|
||||
## 8. 和现有 Goose 服务的关系
|
||||
## 9. 和现有 Goose 服务的关系
|
||||
|
||||
这一步是旁路安装,不会改动现有 Goose 服务。
|
||||
|
||||
@@ -163,7 +222,22 @@ openhands serve --mount-cwd
|
||||
- OpenHands 先作为独立执行器接入
|
||||
- 真正切流之前,先做只读审计和联调验证
|
||||
|
||||
## 9. 官方参考
|
||||
## 10. 本地验证结论
|
||||
|
||||
本仓库本机 `Colima` 已验证通过:
|
||||
|
||||
- OpenHands Web 服务可访问
|
||||
- OpenHands 数据落本地 Postgres
|
||||
- 官方镜像直接切 PG 有迁移坑,因此当前 compose 使用的是仓库内的薄 overlay 镜像修复
|
||||
|
||||
相关文件:
|
||||
|
||||
- `/Users/john/Project/Memind/docker-compose.openhands-colima.yml`
|
||||
- `/Users/john/Project/Memind/docker/openhands-colima/Dockerfile`
|
||||
- `/Users/john/Project/Memind/docker/openhands-colima/002.py`
|
||||
- `/Users/john/Project/Memind/docker/openhands-colima/010.py`
|
||||
|
||||
## 11. 官方参考
|
||||
|
||||
- OpenHands 安装文档
|
||||
- OpenHands GUI Server
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
4. 103 **禁止** `npm install`、`npm run build`、在线改源码后继续运行。
|
||||
5. 切换后必须通过 Portal 健康检查;失败立即回滚。
|
||||
|
||||
补充:
|
||||
|
||||
- `103 / Studio` runtime 发布入口:`bash scripts/release-portal-runtime-prod.sh`
|
||||
- `105` runtime 发布入口:`bash scripts/release-portal-runtime-105.sh`
|
||||
- 两者都必须是 **runtime artifact**,不要直接发源码树
|
||||
- `105` 与 `103` 的关键差异是:
|
||||
`105` 为 Linux 环境,**不要携带本机 Mac 的 `node_modules`**;应在 `105` 切换前本机执行 `npm install --omit=dev --no-package-lock`
|
||||
|
||||
## 唯一入口
|
||||
|
||||
```bash
|
||||
@@ -19,6 +27,13 @@ bash scripts/release-portal-runtime-prod.sh --dry-run
|
||||
bash scripts/release-portal-runtime-prod.sh
|
||||
```
|
||||
|
||||
如果目标环境是 `105`:
|
||||
|
||||
```bash
|
||||
bash scripts/release-portal-runtime-105.sh --dry-run
|
||||
bash scripts/release-portal-runtime-105.sh --yes
|
||||
```
|
||||
|
||||
## 产物发布流程
|
||||
|
||||
1. 本地 commit 当前改动。
|
||||
@@ -32,6 +47,24 @@ bash scripts/release-portal-runtime-prod.sh
|
||||
9. 启动 Portal,检查 `127.0.0.1:8081/api/status` 为 200。
|
||||
10. 旧源码 live 目录移入 `archives/`,线上不再保留可运行源码树。
|
||||
|
||||
## 105 runtime 发布差异
|
||||
|
||||
`105` 当前不是 launchd,而是 `systemd` 服务:
|
||||
|
||||
```text
|
||||
goose-h5.service
|
||||
WorkingDirectory=/root/tkmind_go/ui/h5
|
||||
EnvironmentFile=/root/tkmind_go/ui/h5/.env
|
||||
ExecStart=/opt/node/bin/node /root/tkmind_go/ui/h5/server.mjs
|
||||
```
|
||||
|
||||
所以 `105` 发布时要额外满足:
|
||||
|
||||
1. 保留 `.env` / `MindSpace/` / `data/` / `users/` / `logs/` / `public/plaza-covers/`
|
||||
2. 远端切换前在新的 runtime 目录执行 Linux 依赖安装
|
||||
3. 最终用 `systemctl restart goose-h5` 拉起服务
|
||||
4. 健康检查目标是 `http://127.0.0.1:8080/api/status`
|
||||
|
||||
## 禁止事项
|
||||
|
||||
- 禁止 `rsync_to_server.sh`
|
||||
|
||||
+24
-3
@@ -374,8 +374,9 @@ function rowToExecutorBinding(row, keyPublic = null) {
|
||||
|
||||
function executorEnvForProfile(executor, profile, model, { includeSecret = false } = {}) {
|
||||
const apiKeyValue = includeSecret ? profile.apiKey : '[hidden]';
|
||||
const baseUrl = resolveApiBaseUrl(profile.apiUrl);
|
||||
const providerId = profile.providerId;
|
||||
const resolvedApiUrl = profile.apiUrl || BUILTIN_PROVIDER_TEST_URLS[providerId] || '';
|
||||
const baseUrl = resolveApiBaseUrl(resolvedApiUrl);
|
||||
const common = {
|
||||
TKMIND_EXECUTOR: executor,
|
||||
TKMIND_EXECUTOR_PROVIDER: providerId,
|
||||
@@ -436,7 +437,25 @@ function normalizeLaunchMode(mode) {
|
||||
return normalized === 'serve' ? 'serve' : 'headless';
|
||||
}
|
||||
|
||||
function resolveExecutorCommand(executor) {
|
||||
const candidates =
|
||||
executor === 'aider'
|
||||
? [process.env.AIDER_BIN, process.env.GOOSE_AIDER_BIN, 'aider']
|
||||
: executor === 'openhands'
|
||||
? [process.env.OPENHANDS_BIN, process.env.GOOSE_OPENHANDS_BIN, 'openhands']
|
||||
: [executor];
|
||||
return candidates.map((item) => String(item ?? '').trim()).find(Boolean) ?? executor;
|
||||
}
|
||||
|
||||
function commandExists(command) {
|
||||
if (path.isAbsolute(command)) {
|
||||
try {
|
||||
fs.accessSync(command, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const probe = spawnSync('sh', ['-lc', `command -v ${JSON.stringify(command).slice(1, -1)}`], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
@@ -541,11 +560,12 @@ export function buildExecutorLaunchPlan(runtime, options = {}) {
|
||||
|
||||
if (runtime.executor === 'aider') {
|
||||
const hasInstruction = Boolean(instruction);
|
||||
const command = resolveExecutorCommand('aider');
|
||||
return {
|
||||
ok: true,
|
||||
executor: 'aider',
|
||||
cwd,
|
||||
command: 'aider',
|
||||
command,
|
||||
args: [
|
||||
'--model',
|
||||
runtime.model,
|
||||
@@ -568,6 +588,7 @@ export function buildExecutorLaunchPlan(runtime, options = {}) {
|
||||
message: 'OpenHands headless 模式需要 instruction',
|
||||
};
|
||||
}
|
||||
const command = resolveExecutorCommand('openhands');
|
||||
const args =
|
||||
mode === 'serve'
|
||||
? ['serve', '--mount-cwd']
|
||||
@@ -576,7 +597,7 @@ export function buildExecutorLaunchPlan(runtime, options = {}) {
|
||||
ok: true,
|
||||
executor: 'openhands',
|
||||
cwd,
|
||||
command: 'openhands',
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
notes: [
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-core.mjs",
|
||||
"setup:dev-launchagent": "bash scripts/install-memind-dev-launchagent.sh",
|
||||
"setup:dev-launchagent:uninstall": "bash scripts/install-memind-dev-launchagent.sh --uninstall",
|
||||
"dev:all": "node scripts/dev.mjs",
|
||||
"dev:plaza": "node scripts/dev-plaza.mjs",
|
||||
"start:plaza": "node scripts/start-plaza-prod.mjs",
|
||||
|
||||
@@ -14,6 +14,8 @@ const schemaFile = path.join(root, 'schema.sql');
|
||||
const esbuildBin = path.join(root, 'node_modules', '.pnpm', 'node_modules', '.bin', 'esbuild');
|
||||
const skipBuild = process.argv.includes('--skip-build');
|
||||
const skipNodeModules = process.argv.includes('--skip-node-modules');
|
||||
const runtimeNodeTarget = process.env.PORTAL_RUNTIME_NODE_TARGET || 'node24';
|
||||
const runtimeInstallMode = process.env.PORTAL_RUNTIME_INSTALL_MODE || 'bundle-node-modules';
|
||||
|
||||
const externalPackages = [
|
||||
'@node-rs/argon2',
|
||||
@@ -84,7 +86,7 @@ async function bundleServer() {
|
||||
'--bundle',
|
||||
'--platform=node',
|
||||
'--format=esm',
|
||||
'--target=node24',
|
||||
`--target=${runtimeNodeTarget}`,
|
||||
'--outfile=.runtime/portal/server.mjs',
|
||||
'--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
|
||||
];
|
||||
@@ -104,7 +106,7 @@ async function bundleSandboxMcp() {
|
||||
'--bundle',
|
||||
'--platform=node',
|
||||
'--format=esm',
|
||||
'--target=node24',
|
||||
`--target=${runtimeNodeTarget}`,
|
||||
'--outfile=.runtime/portal/mindspace-sandbox-mcp.mjs',
|
||||
'--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
|
||||
];
|
||||
@@ -175,10 +177,19 @@ async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir, sourceNodeModul
|
||||
|
||||
async function writeMetadata() {
|
||||
const packageJson = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8'));
|
||||
const runtimeDependencies = Object.fromEntries(
|
||||
externalPackages
|
||||
.filter((pkg) => packageJson.dependencies?.[pkg])
|
||||
.map((pkg) => [pkg, packageJson.dependencies[pkg]]),
|
||||
);
|
||||
const runtimePackageJson = {
|
||||
name: `${packageJson.name}-portal-runtime`,
|
||||
private: true,
|
||||
type: 'module',
|
||||
engines: {
|
||||
node: '>=22',
|
||||
},
|
||||
dependencies: runtimeDependencies,
|
||||
};
|
||||
await writeFile(path.join(runtimeRoot, 'package.json'), `${JSON.stringify(runtimePackageJson, null, 2)}\n`);
|
||||
await writeFile(
|
||||
@@ -203,14 +214,15 @@ async function writeMetadata() {
|
||||
'export TKMIND_API_TARGETS="${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}"',
|
||||
'export TKMIND_API_TARGET="${TKMIND_API_TARGET:-https://127.0.0.1:18006}"',
|
||||
'export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1:-https://127.0.0.1:18007}"',
|
||||
'export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-/usr/local/bin/node}"',
|
||||
'export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-/opt/portal/mindspace-sandbox-mcp.mjs}"',
|
||||
'',
|
||||
'NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"',
|
||||
'if [[ ! -x "${NODE_BIN}" ]]; then',
|
||||
' NODE_BIN="$(command -v node)"',
|
||||
'fi',
|
||||
'',
|
||||
'export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-${NODE_BIN}}"',
|
||||
'export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-${ROOT}/mindspace-sandbox-mcp.mjs}"',
|
||||
'',
|
||||
'exec "${NODE_BIN}" "${ROOT}/server.mjs"',
|
||||
'',
|
||||
].join('\n'),
|
||||
@@ -241,6 +253,9 @@ async function writeMetadata() {
|
||||
'Bundled alongside server.mjs (required for sandbox-fs MCP):',
|
||||
' mindspace-sandbox-mcp.mjs (esbuild bundle; includes schedule-service deps)',
|
||||
'',
|
||||
`Runtime install mode: ${runtimeInstallMode}`,
|
||||
`Bundle target: ${runtimeNodeTarget}`,
|
||||
'',
|
||||
'Key runtime differences must stay in .env, not in the artifact:',
|
||||
' DATABASE_URL / MYSQL_*',
|
||||
' H5_PUBLIC_BASE_URL',
|
||||
@@ -261,7 +276,9 @@ async function main() {
|
||||
await copyRuntimeAssets();
|
||||
await bundleServer();
|
||||
await bundleSandboxMcp();
|
||||
await copyNodeModules();
|
||||
if (runtimeInstallMode === 'bundle-node-modules' && !skipNodeModules) {
|
||||
await copyNodeModules();
|
||||
}
|
||||
await writeMetadata();
|
||||
await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-portal-prod.sh'), 0o755);
|
||||
await fs.chmod(path.join(runtimeRoot, 'scripts', 'wechat-mp-menu.mjs'), 0o755);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* LaunchAgent / 后台常驻版 dev 栈:portal + admin + ops + vite。
|
||||
* 单个子进程异常退出时会自动重启;启动阶段失败则整体退出,由 launchd KeepAlive 重试。
|
||||
*/
|
||||
import { spawn, execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const opsDir = path.join(root, 'ops');
|
||||
const nodeBin = process.execPath;
|
||||
const portalPort = Number(process.env.H5_PORT ?? 8081);
|
||||
const vitePort = Number(process.env.VITE_PORT ?? 5173);
|
||||
const plazaPort = Number(process.env.PLAZA_PORT ?? 3001);
|
||||
const opsPort = Number(process.env.OPS_PORT ?? 3002);
|
||||
const adminPort = Number(process.env.ADMIN_PORT ?? 8082);
|
||||
const portalUrl = `http://127.0.0.1:${portalPort}`;
|
||||
const adminUrl = `http://127.0.0.1:${adminPort}`;
|
||||
const plazaPublicBase = (
|
||||
process.env.PLAZA_PUBLIC_BASE ?? `http://127.0.0.1:${plazaPort}`
|
||||
).replace(/\/$/, '');
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile(path.join(root, '../../.env.local'));
|
||||
loadEnvFile(path.join(root, '.env'));
|
||||
|
||||
const logFile =
|
||||
process.env.MEMIND_DEV_LOG ?? path.join(os.homedir(), 'Library/Logs/memind-dev.log');
|
||||
|
||||
function log(message) {
|
||||
const line = `[${new Date().toISOString()}] ${message}\n`;
|
||||
try {
|
||||
fs.appendFileSync(logFile, line);
|
||||
} catch {
|
||||
process.stdout.write(line);
|
||||
}
|
||||
}
|
||||
|
||||
function freePort(port) {
|
||||
try {
|
||||
execSync(`lsof -ti TCP:${port} -sTCP:LISTEN | xargs kill -9`, { stdio: 'ignore' });
|
||||
} catch {
|
||||
// port already free
|
||||
}
|
||||
}
|
||||
|
||||
let fullyStarted = false;
|
||||
let stopping = false;
|
||||
const children = { server: null, admin: null, ops: null, vite: null };
|
||||
|
||||
function shutdown(code = 0) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
for (const child of Object.values(children)) {
|
||||
child?.kill('SIGTERM');
|
||||
}
|
||||
setTimeout(() => process.exit(code), 300);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown(0));
|
||||
process.on('SIGTERM', () => shutdown(0));
|
||||
|
||||
function attachExitHandler(label, child, respawn) {
|
||||
child.on('exit', (code, signal) => {
|
||||
if (stopping || signal === 'SIGTERM') return;
|
||||
if (!code || code === 0) return;
|
||||
if (!fullyStarted) {
|
||||
log(`[${label}] 启动阶段退出 code=${code}`);
|
||||
shutdown(code);
|
||||
return;
|
||||
}
|
||||
log(`[${label}] 异常退出 code=${code},3 秒后重启...`);
|
||||
setTimeout(() => {
|
||||
if (stopping) return;
|
||||
respawn();
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
function spawnPortal() {
|
||||
const child = spawn(nodeBin, [path.join(root, 'server.mjs')], {
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
children.server = child;
|
||||
attachExitHandler('portal', child, spawnPortal);
|
||||
return child;
|
||||
}
|
||||
|
||||
function spawnAdmin() {
|
||||
const child = spawn(nodeBin, [path.join(root, 'admin-server.mjs')], {
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
children.admin = child;
|
||||
attachExitHandler('admin', child, spawnAdmin);
|
||||
return child;
|
||||
}
|
||||
|
||||
function spawnOps() {
|
||||
const child = spawn('npm', ['run', 'dev'], {
|
||||
cwd: opsDir,
|
||||
env: { ...process.env, ...opsEnv },
|
||||
stdio: 'inherit',
|
||||
});
|
||||
children.ops = child;
|
||||
attachExitHandler('ops', child, spawnOps);
|
||||
return child;
|
||||
}
|
||||
|
||||
function spawnVite() {
|
||||
const child = spawn('npx', ['vite'], {
|
||||
cwd: root,
|
||||
env: { ...process.env, ...viteEnv },
|
||||
stdio: 'inherit',
|
||||
});
|
||||
children.vite = child;
|
||||
attachExitHandler('vite', child, spawnVite);
|
||||
return child;
|
||||
}
|
||||
|
||||
async function waitFor(url, check, label, retries = 60) {
|
||||
for (let i = 0; i < retries; i += 1) {
|
||||
try {
|
||||
if (await check(url)) return;
|
||||
} catch {
|
||||
// still starting
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
throw new Error(`${label} 未在 ${url} 启动`);
|
||||
}
|
||||
|
||||
const mindSpacePublicBase = (
|
||||
process.env.H5_PUBLIC_BASE_URL ?? `http://127.0.0.1:${vitePort}`
|
||||
).replace(/\/$/, '');
|
||||
|
||||
const viteEnv = {
|
||||
VITE_PLAZA_BASE: plazaPublicBase,
|
||||
VITE_MINDSPACE_BASE: mindSpacePublicBase,
|
||||
};
|
||||
|
||||
const opsEnv = {
|
||||
OPS_API_PROXY: portalUrl,
|
||||
OPS_ADMIN_PROXY: adminUrl,
|
||||
VITE_PLAZA_BASE: plazaPublicBase,
|
||||
VITE_MINDSPACE_BASE: mindSpacePublicBase,
|
||||
};
|
||||
|
||||
log(`清理端口 ${portalPort} / ${adminPort} / ${vitePort} / ${opsPort}...`);
|
||||
freePort(portalPort);
|
||||
freePort(adminPort);
|
||||
freePort(vitePort);
|
||||
freePort(opsPort);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
log('启动 portal (server.mjs)...');
|
||||
spawnPortal();
|
||||
|
||||
try {
|
||||
await waitFor(portalUrl, async (url) => (await fetch(`${url}/auth/status`)).ok, 'Portal');
|
||||
log(`Portal 就绪: ${portalUrl}`);
|
||||
|
||||
log('启动 memind_adm (admin-server.mjs)...');
|
||||
spawnAdmin();
|
||||
await waitFor(adminUrl, async (url) => (await fetch(`${url}/healthz`)).ok, 'Admin');
|
||||
log(`memind_adm 就绪: ${adminUrl}`);
|
||||
|
||||
log(`启动 Ops 后台 @ http://127.0.0.1:${opsPort}/ops/`);
|
||||
spawnOps();
|
||||
await waitFor(
|
||||
`http://127.0.0.1:${opsPort}`,
|
||||
async (url) => (await fetch(`${url}/ops/`)).ok,
|
||||
'Ops',
|
||||
40,
|
||||
);
|
||||
log(`Ops 就绪: http://127.0.0.1:${opsPort}/ops/`);
|
||||
|
||||
log(`启动 Vite @ http://127.0.0.1:${vitePort}`);
|
||||
spawnVite();
|
||||
await waitFor(
|
||||
`http://127.0.0.1:${vitePort}`,
|
||||
async (url) => {
|
||||
const [rootRes, mainRes] = await Promise.all([
|
||||
fetch(`${url}/`),
|
||||
fetch(`${url}/src/main.tsx`),
|
||||
]);
|
||||
return rootRes.ok && mainRes.ok;
|
||||
},
|
||||
'Vite',
|
||||
);
|
||||
log(`Vite 就绪: http://127.0.0.1:${vitePort}`);
|
||||
|
||||
fullyStarted = true;
|
||||
log('');
|
||||
log('本地服务(LaunchAgent 常驻):');
|
||||
log(` MindSpace UI http://127.0.0.1:${vitePort}/?preview=mindspace`);
|
||||
log(` Ops 审核后台 http://127.0.0.1:${opsPort}/ops/`);
|
||||
log(` API / Portal ${portalUrl}`);
|
||||
log(` memind_adm ${adminUrl}`);
|
||||
log('');
|
||||
log('Plaza 未包含在此 Agent 内;需要时请单独运行 pnpm dev:plaza');
|
||||
} catch (err) {
|
||||
log(err instanceof Error ? err.message : String(err));
|
||||
shutdown(1);
|
||||
}
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { createLlmProviderService } from '../llm-providers.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {
|
||||
executor: String(argv[0] ?? '').trim(),
|
||||
purpose: 'default',
|
||||
shell: false,
|
||||
};
|
||||
for (let i = 1; i < argv.length; i += 1) {
|
||||
const item = argv[i];
|
||||
if (item === '--purpose') out.purpose = String(argv[++i] ?? out.purpose);
|
||||
else if (item === '--shell') out.shell = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
loadEnvFile(path.join(root, '.env'));
|
||||
loadEnvFile(path.join(root, '.env.local'));
|
||||
|
||||
const parsed = parseArgs(process.argv.slice(2));
|
||||
if (!parsed.executor) {
|
||||
console.error('Usage: node scripts/executor-env.mjs <executor> [--purpose default] [--shell]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pool = createDbPool();
|
||||
try {
|
||||
const svc = createLlmProviderService(pool, {
|
||||
apiTarget: process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
|
||||
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
|
||||
});
|
||||
const runtime = await svc.getExecutorRuntimeConfig(parsed.executor, {
|
||||
purpose: parsed.purpose,
|
||||
includeSecret: true,
|
||||
});
|
||||
if (!runtime?.ok) {
|
||||
console.error(runtime?.message ?? 'executor runtime unavailable');
|
||||
process.exit(2);
|
||||
}
|
||||
const env = runtime.env ?? {};
|
||||
if (parsed.shell) {
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
process.stdout.write(`export ${key}=${shellQuote(value)}\n`);
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(`${JSON.stringify({ executor: parsed.executor, env })}\n`);
|
||||
}
|
||||
} finally {
|
||||
await pool.end().catch(() => {});
|
||||
}
|
||||
Executable
+159
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
ENV_FILE="${ENV_FILE:-${ROOT}/.env}"
|
||||
LABEL="${LABEL:-cn.tkmind.memind-dev}"
|
||||
PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist"
|
||||
LOG_DIR="${HOME}/Library/Logs"
|
||||
LOG_FILE="${LOG_DIR}/memind-dev.log"
|
||||
DAEMON_SCRIPT="${ROOT}/scripts/dev-core-daemon.mjs"
|
||||
UID_NUM="$(id -u)"
|
||||
GUI="gui/${UID_NUM}"
|
||||
PATH_EXPORT="/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
用法:
|
||||
bash scripts/install-memind-dev-launchagent.sh # 安装并启动
|
||||
bash scripts/install-memind-dev-launchagent.sh --uninstall
|
||||
|
||||
环境变量:
|
||||
ROOT 仓库根目录 (默认: ${ROOT})
|
||||
LABEL LaunchAgent 标识 (默认: ${LABEL})
|
||||
NODE_BIN node 可执行文件路径
|
||||
EOF
|
||||
}
|
||||
|
||||
uninstall() {
|
||||
if launchctl print "${GUI}/${LABEL}" >/dev/null 2>&1; then
|
||||
launchctl bootout "${GUI}/${LABEL}" 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
rm -f "${PLIST}"
|
||||
echo "已卸载 LaunchAgent: ${LABEL}"
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--uninstall" ]]; then
|
||||
uninstall
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
echo "未找到环境文件: ${ENV_FILE}" >&2
|
||||
echo "请先 cp .env.example .env 并配置数据库等变量" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "${DAEMON_SCRIPT}" ]]; then
|
||||
echo "未找到 daemon 脚本: ${DAEMON_SCRIPT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "${ENV_FILE}"
|
||||
set +a
|
||||
|
||||
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
if [[ ! -x "${NODE_BIN}" ]]; then
|
||||
NODE_BIN="$(command -v node || true)"
|
||||
fi
|
||||
if [[ ! -x "${NODE_BIN}" ]]; then
|
||||
echo "未找到 node,请安装 Node.js 或设置 NODE_BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "${ROOT}/node_modules" ]]; then
|
||||
echo "未找到 node_modules,请先在 ${ROOT} 执行 pnpm install" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "${ROOT}/ops/node_modules" ]]; then
|
||||
echo "未找到 ops/node_modules,请先在 ${ROOT}/ops 执行 npm install" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${HOME}/Library/LaunchAgents" "${LOG_DIR}"
|
||||
|
||||
cat >"${PLIST}" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>${LABEL}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>${NODE_BIN}</string>
|
||||
<string>${DAEMON_SCRIPT}</string>
|
||||
</array>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>${ROOT}</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PATH</key>
|
||||
<string>${PATH_EXPORT}</string>
|
||||
<key>HOME</key>
|
||||
<string>${HOME}</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>15</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${LOG_FILE}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${LOG_FILE}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
plutil -lint "${PLIST}"
|
||||
|
||||
if launchctl print "${GUI}/${LABEL}" >/dev/null 2>&1; then
|
||||
launchctl bootout "${GUI}/${LABEL}" 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
launchctl bootstrap "${GUI}" "${PLIST}"
|
||||
launchctl enable "${GUI}/${LABEL}"
|
||||
launchctl kickstart -k "${GUI}/${LABEL}"
|
||||
|
||||
echo "等待 H5 开发栈就绪..."
|
||||
portal_port="${H5_PORT:-8081}"
|
||||
vite_port="${VITE_PORT:-5173}"
|
||||
ready=0
|
||||
for _ in $(seq 1 90); do
|
||||
if curl -sf "http://127.0.0.1:${portal_port}/auth/status" >/dev/null 2>&1 \
|
||||
&& curl -sf "http://127.0.0.1:${vite_port}/" >/dev/null 2>&1; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Memind 本地开发栈 (LaunchAgent) ==="
|
||||
launchctl list | grep "${LABEL}" || true
|
||||
if [[ "${ready}" -eq 1 ]]; then
|
||||
echo "Portal: http://127.0.0.1:${portal_port}"
|
||||
echo "H5 UI: http://127.0.0.1:${vite_port}/?preview=mindspace"
|
||||
else
|
||||
echo "服务尚未就绪,请查看日志: tail -f ${LOG_FILE}"
|
||||
fi
|
||||
echo ""
|
||||
echo "LaunchAgent: ${PLIST}"
|
||||
echo "Log file: ${LOG_FILE}"
|
||||
echo ""
|
||||
echo "常用命令:"
|
||||
echo " launchctl kickstart -k ${GUI}/${LABEL} # 重启"
|
||||
echo " tail -f ${LOG_FILE} # 看日志"
|
||||
echo " bash scripts/install-memind-dev-launchagent.sh --uninstall"
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
COMPOSE_FILE="${ROOT_DIR}/docker-compose.openhands-colima.yml"
|
||||
|
||||
export OPENHANDS_WORKSPACE="${OPENHANDS_WORKSPACE:-${ROOT_DIR}}"
|
||||
export OPENHANDS_DOCKER_SOCK="${OPENHANDS_DOCKER_SOCK:-/Users/john/.colima/default/docker.sock}"
|
||||
export OPENHANDS_CONFIG_DIR="${OPENHANDS_CONFIG_DIR:-/Users/john/.openhands}"
|
||||
export OPENHANDS_PORT="${OPENHANDS_PORT:-3000}"
|
||||
export OPENHANDS_SANDBOX_UID="${OPENHANDS_SANDBOX_UID:-$(id -u)}"
|
||||
export OPENHANDS_DB_NAME="${OPENHANDS_DB_NAME:-openhands}"
|
||||
export OPENHANDS_DB_USER="${OPENHANDS_DB_USER:-openhands}"
|
||||
export OPENHANDS_DB_PASS="${OPENHANDS_DB_PASS:-openhands_dev_password}"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "docker not found in PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -S "${OPENHANDS_DOCKER_SOCK}" ]; then
|
||||
echo "docker socket not found: ${OPENHANDS_DOCKER_SOCK}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "${OPENHANDS_CONFIG_DIR}"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: scripts/openhands-colima.sh <pull|up|down|restart|logs|status>
|
||||
|
||||
Environment:
|
||||
OPENHANDS_WORKSPACE Workspace mounted into /workspace
|
||||
OPENHANDS_DOCKER_SOCK Docker socket mounted into the service container
|
||||
OPENHANDS_CONFIG_DIR Legacy host config directory (kept for CLI wrappers)
|
||||
OPENHANDS_PORT Host port published for the GUI server
|
||||
OPENHANDS_DB_NAME Postgres database name for OpenHands
|
||||
OPENHANDS_DB_USER Postgres user for OpenHands
|
||||
OPENHANDS_DB_PASS Postgres password for OpenHands
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd="${1:-status}"
|
||||
shift || true
|
||||
|
||||
compose() {
|
||||
docker compose -f "${COMPOSE_FILE}" "$@"
|
||||
}
|
||||
|
||||
case "${cmd}" in
|
||||
build)
|
||||
compose build openhands
|
||||
;;
|
||||
pull)
|
||||
compose pull postgres
|
||||
;;
|
||||
up)
|
||||
compose up -d postgres openhands
|
||||
;;
|
||||
down)
|
||||
compose down
|
||||
;;
|
||||
restart)
|
||||
compose up -d --force-recreate postgres openhands
|
||||
;;
|
||||
logs)
|
||||
compose logs -f --tail=200 postgres openhands
|
||||
;;
|
||||
status)
|
||||
compose ps
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="/Users/john/Project/Memind"
|
||||
ENV_SCRIPT="${ROOT_DIR}/scripts/executor-env.mjs"
|
||||
OPENHANDS_BIN_DEFAULT="/Users/john/.local/bin/openhands"
|
||||
OPENHANDS_BIN="${OPENHANDS_CLI_BIN:-${OPENHANDS_BIN:-${OPENHANDS_BIN_DEFAULT}}}"
|
||||
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
|
||||
|
||||
if [[ -z "${NODE_BIN}" ]]; then
|
||||
echo "node not found in PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -x "${OPENHANDS_BIN}" ]]; then
|
||||
echo "OpenHands CLI not executable: ${OPENHANDS_BIN}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
eval "$("${NODE_BIN}" "${ENV_SCRIPT}" openhands --shell)"
|
||||
|
||||
exec "${OPENHANDS_BIN}" "$@"
|
||||
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
HOST="${H5_DEPLOY_HOST:-root@120.26.184.105}"
|
||||
REMOTE_ROOT="${REMOTE_ROOT:-/root/tkmind_go}"
|
||||
APP_DIR="${APP_DIR:-${REMOTE_ROOT}/ui/h5}"
|
||||
INCOMING_DIR="${INCOMING_DIR:-${REMOTE_ROOT}/incoming/memind-portal-runtime-105}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-${REMOTE_ROOT}/backups}"
|
||||
ARCHIVE_DIR="${ARCHIVE_DIR:-${REMOTE_ROOT}/archives}"
|
||||
SERVICE_NAME="${SERVICE_NAME:-goose-h5}"
|
||||
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8080/api/status}"
|
||||
RUNTIME_ROOT="${ROOT}/.runtime/portal"
|
||||
RELEASE_ID="${RELEASE_ID:-$(date +%Y%m%d-%H%M%S)}"
|
||||
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/memind-portal-runtime-105.XXXXXX")"
|
||||
BUNDLE_PATH="${TMP_DIR}/memind-portal-runtime-105-${RELEASE_ID}.tar.gz"
|
||||
MANIFEST_PATH="${TMP_DIR}/memind-portal-runtime-105-${RELEASE_ID}.manifest.txt"
|
||||
SHA_PATH="${TMP_DIR}/memind-portal-runtime-105-${RELEASE_ID}.sha256"
|
||||
DRY_RUN=0
|
||||
SKIP_BUILD=0
|
||||
SKIP_TESTS=0
|
||||
AUTO_YES=0
|
||||
|
||||
cleanup() {
|
||||
rm -rf "${TMP_DIR}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
bash scripts/release-portal-runtime-105.sh [--dry-run] [--skip-build] [--skip-tests] [--yes]
|
||||
|
||||
Behavior:
|
||||
1. Optionally runs targeted local tests
|
||||
2. Builds the local Portal runtime artifact
|
||||
3. Packages .runtime/portal into a tarball
|
||||
4. Uploads the tarball to 105
|
||||
5. Replaces the 105 live runtime while preserving config and persisted data
|
||||
|
||||
Preserved on 105:
|
||||
.env, MindSpace/, data/, users/, logs/, public/plaza-covers/
|
||||
EOF
|
||||
}
|
||||
|
||||
say() {
|
||||
printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
;;
|
||||
--skip-build)
|
||||
SKIP_BUILD=1
|
||||
;;
|
||||
--skip-tests)
|
||||
SKIP_TESTS=1
|
||||
;;
|
||||
--yes)
|
||||
AUTO_YES=1
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
cd "${ROOT}"
|
||||
|
||||
say "检查 105 SSH"
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo portal-runtime-105-ssh-ok" >/dev/null
|
||||
|
||||
if [[ "${SKIP_TESTS}" -ne 1 ]]; then
|
||||
say "运行微信相关本地测试"
|
||||
node --test wechat-mp.test.mjs wechat-oauth.test.mjs wechat-pay.test.mjs user-auth.test.mjs capabilities.test.mjs llm-providers.test.mjs
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_BUILD}" -ne 1 ]]; then
|
||||
say "构建 Portal runtime"
|
||||
PORTAL_RUNTIME_NODE_TARGET=node22 PORTAL_RUNTIME_INSTALL_MODE=remote-npm-install \
|
||||
node scripts/build-portal-runtime.mjs --skip-node-modules
|
||||
fi
|
||||
|
||||
verify_runtime_artifact() {
|
||||
local missing=0
|
||||
for required in server.mjs mindspace-sandbox-mcp.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/load-env.mjs scripts/wechat-mp-menu.mjs; do
|
||||
if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then
|
||||
echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
if [[ "${missing}" -ne 0 ]]; then
|
||||
echo "请先执行: node scripts/build-portal-runtime.mjs" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
verify_runtime_artifact
|
||||
|
||||
if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then
|
||||
say "发布确认"
|
||||
echo "目标主机: ${HOST}"
|
||||
echo "目标目录: ${APP_DIR}"
|
||||
echo "systemd 服务: ${SERVICE_NAME}"
|
||||
echo "发布编号: ${RELEASE_ID}"
|
||||
echo "本地 HEAD: $(git -C "${ROOT}" rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
read -r -p "确认继续发布到 105? [y/N] " confirm </dev/tty
|
||||
[[ "${confirm}" =~ ^[Yy]$ ]] || exit 0
|
||||
fi
|
||||
|
||||
say "生成发布清单"
|
||||
{
|
||||
echo "release_id=${RELEASE_ID}"
|
||||
echo "created_at=$(date '+%Y-%m-%d %H:%M:%S %z')"
|
||||
echo "host=$(hostname)"
|
||||
echo "git_head=$(git -C "${ROOT}" rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
echo "git_branch=$(git -C "${ROOT}" branch --show-current 2>/dev/null || echo detached)"
|
||||
echo "artifact=.runtime/portal"
|
||||
echo "target=105"
|
||||
echo "persisted_items=.env, MindSpace, data, users, logs, public/plaza-covers"
|
||||
} > "${MANIFEST_PATH}"
|
||||
|
||||
say "打包 Portal runtime artifact"
|
||||
tar -czf "${BUNDLE_PATH}" -C "${RUNTIME_ROOT}" .
|
||||
checksum="$(shasum -a 256 "${BUNDLE_PATH}" | awk '{print $1}')"
|
||||
printf '%s %s\n' "${checksum}" "$(basename "${BUNDLE_PATH}")" > "${SHA_PATH}"
|
||||
|
||||
if [[ "${DRY_RUN}" -eq 1 ]]; then
|
||||
say "dry-run 完成"
|
||||
ls -lh "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
say "上传 Portal runtime 到 105"
|
||||
ssh -o BatchMode=yes "${HOST}" "mkdir -p '${INCOMING_DIR}' '${BACKUP_DIR}' '${ARCHIVE_DIR}'"
|
||||
scp -q "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}" "${HOST}:${INCOMING_DIR}/"
|
||||
|
||||
say "在 105 切换到新的 runtime"
|
||||
ssh -o BatchMode=yes "${HOST}" \
|
||||
"RELEASE_ID='${RELEASE_ID}' APP_DIR='${APP_DIR}' INCOMING_DIR='${INCOMING_DIR}' BACKUP_DIR='${BACKUP_DIR}' ARCHIVE_DIR='${ARCHIVE_DIR}' HEALTH_URL='${HEALTH_URL}' SERVICE_NAME='${SERVICE_NAME}' /bin/bash" <<'REMOTE_SCRIPT'
|
||||
set -euo pipefail
|
||||
|
||||
RUNTIME_DIR="${APP_DIR}.runtime-${RELEASE_ID}"
|
||||
OLD_LIVE_DIR="${ARCHIVE_DIR}/h5-before-${RELEASE_ID}"
|
||||
BUNDLE="${INCOMING_DIR}/memind-portal-runtime-105-${RELEASE_ID}.tar.gz"
|
||||
MANIFEST="${INCOMING_DIR}/memind-portal-runtime-105-${RELEASE_ID}.manifest.txt"
|
||||
SHA_FILE="${INCOMING_DIR}/memind-portal-runtime-105-${RELEASE_ID}.sha256"
|
||||
FULL_BACKUP_TAR="${BACKUP_DIR}/h5-full-${RELEASE_ID}-before.tar.gz"
|
||||
PERSIST_BACKUP_TAR="${BACKUP_DIR}/h5-persisted-${RELEASE_ID}-before.tar.gz"
|
||||
PERSISTED_ITEMS=(
|
||||
".env"
|
||||
"MindSpace"
|
||||
"data"
|
||||
"users"
|
||||
"logs"
|
||||
"public/plaza-covers"
|
||||
)
|
||||
|
||||
say() {
|
||||
printf '\n[remote %s] %s\n' "$(date +%H:%M:%S)" "$*"
|
||||
}
|
||||
|
||||
rollback() {
|
||||
if [[ -d "${OLD_LIVE_DIR}" && ! -d "${APP_DIR}" ]]; then
|
||||
mv "${OLD_LIVE_DIR}" "${APP_DIR}"
|
||||
fi
|
||||
systemctl restart "${SERVICE_NAME}" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
trap 'rollback' ERR
|
||||
|
||||
[[ -f "${BUNDLE}" ]] || { echo "missing bundle: ${BUNDLE}" >&2; exit 1; }
|
||||
[[ -f "${MANIFEST}" ]] || { echo "missing manifest: ${MANIFEST}" >&2; exit 1; }
|
||||
[[ -f "${SHA_FILE}" ]] || { echo "missing sha file: ${SHA_FILE}" >&2; exit 1; }
|
||||
[[ -f "${APP_DIR}/.env" ]] || { echo "missing required config: ${APP_DIR}/.env" >&2; exit 1; }
|
||||
|
||||
say "校验产物完整性"
|
||||
cd "${INCOMING_DIR}"
|
||||
shasum -a 256 -c "$(basename "${SHA_FILE}")"
|
||||
|
||||
say "备份当前 live 全目录"
|
||||
COPYFILE_DISABLE=1 tar -czf "${FULL_BACKUP_TAR}" -C "$(dirname "${APP_DIR}")" "$(basename "${APP_DIR}")"
|
||||
|
||||
say "单独备份持久目录"
|
||||
tmp_persist_dir="$(mktemp -d "${TMPDIR:-/tmp}/memind-105-persisted.XXXXXX")"
|
||||
for item in "${PERSISTED_ITEMS[@]}"; do
|
||||
if [[ -e "${APP_DIR}/${item}" ]]; then
|
||||
mkdir -p "${tmp_persist_dir}/$(dirname "${item}")"
|
||||
cp -a "${APP_DIR}/${item}" "${tmp_persist_dir}/$(dirname "${item}")/"
|
||||
fi
|
||||
done
|
||||
COPYFILE_DISABLE=1 tar -czf "${PERSIST_BACKUP_TAR}" -C "${tmp_persist_dir}" .
|
||||
rm -rf "${tmp_persist_dir}"
|
||||
|
||||
say "停止旧 Portal 服务"
|
||||
systemctl stop "${SERVICE_NAME}"
|
||||
lsof -tiTCP:8080 -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
say "准备新的 runtime live 目录"
|
||||
rm -rf "${RUNTIME_DIR}"
|
||||
mkdir -p "${RUNTIME_DIR}"
|
||||
tar -xzf "${BUNDLE}" -C "${RUNTIME_DIR}"
|
||||
cp "${MANIFEST}" "${RUNTIME_DIR}/.release-manifest.txt"
|
||||
|
||||
for item in "${PERSISTED_ITEMS[@]}"; do
|
||||
if [[ -e "${APP_DIR}/${item}" ]]; then
|
||||
rm -rf "${RUNTIME_DIR:?}/${item}"
|
||||
mkdir -p "$(dirname "${RUNTIME_DIR}/${item}")"
|
||||
cp -a "${APP_DIR}/${item}" "$(dirname "${RUNTIME_DIR}/${item}")/"
|
||||
fi
|
||||
done
|
||||
|
||||
say "在 105 安装 Linux 运行依赖"
|
||||
cd "${RUNTIME_DIR}"
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install --omit=dev --no-package-lock
|
||||
|
||||
say "归档旧 live 并切换新 runtime"
|
||||
rm -rf "${OLD_LIVE_DIR}"
|
||||
mv "${APP_DIR}" "${OLD_LIVE_DIR}"
|
||||
mv "${RUNTIME_DIR}" "${APP_DIR}"
|
||||
|
||||
say "启动新的 Portal runtime"
|
||||
systemctl restart "${SERVICE_NAME}"
|
||||
|
||||
say "健康检查"
|
||||
for _ in $(seq 1 60); do
|
||||
portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)"
|
||||
if [[ "${portal_code}" == "200" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)"
|
||||
if [[ "${portal_code}" != "200" ]]; then
|
||||
echo "health check failed: portal=${portal_code}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
say "runtime 切换完成"
|
||||
REMOTE_SCRIPT
|
||||
|
||||
say "105 runtime 发布完成"
|
||||
@@ -191,37 +191,22 @@ if [[ -x "${docker_bin}" ]] && "${docker_bin}" ps --format '{{.Names}}' 2>/dev/n
|
||||
fi
|
||||
fi
|
||||
else
|
||||
api_secret="$(grep -E '^TKMIND_SERVER__SECRET_KEY=' /Users/john/Project/Memind/.env 2>/dev/null | tail -1 | cut -d= -f2- || true)"
|
||||
for port in 18006 18007; do
|
||||
line="$(lsof -nP -iTCP:${port} -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print $1, $2, $9}')"
|
||||
if [[ -z "${line}" ]]; then
|
||||
echo "goosed dependency check failed: port ${port} has no listener" >&2
|
||||
status="$(curl -skS -m 5 -H "X-Secret-Key: ${api_secret}" "https://127.0.0.1:${port}/status" 2>/dev/null || true)"
|
||||
if [[ "${status}" != "ok" ]]; then
|
||||
line="$(lsof -nP -iTCP:${port} -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print $1, $2, $9}')"
|
||||
echo "goosed dependency check failed: port ${port} status check failed (${line:-no listener})" >&2
|
||||
missing=1
|
||||
continue
|
||||
fi
|
||||
command_name="${line%% *}"
|
||||
case "${command_name}" in
|
||||
goosed) ;;
|
||||
*)
|
||||
echo "goosed dependency check failed: port ${port} is listened by ${line}, not goosed" >&2
|
||||
missing=1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! launchctl list 2>/dev/null | grep -q 'cn.tkmind.goosed-18006'; then
|
||||
echo "goosed dependency check failed: launchd service cn.tkmind.goosed-18006 is not loaded" >&2
|
||||
missing=1
|
||||
fi
|
||||
if ! launchctl list 2>/dev/null | grep -q 'cn.tkmind.goosed-18007'; then
|
||||
echo "goosed dependency check failed: launchd service cn.tkmind.goosed-18007 is not loaded" >&2
|
||||
missing=1
|
||||
fi
|
||||
if [[ ! -x /Users/john/Project/tkmind_go/scripts/run-goosed-local.sh ]]; then
|
||||
echo "goosed dependency check failed: missing /Users/john/Project/tkmind_go/scripts/run-goosed-local.sh" >&2
|
||||
missing=1
|
||||
fi
|
||||
if [[ ! -x /Users/john/Project/tkmind_go/target/release/goosed && ! -x /Users/john/Project/tkmind_go/target/debug/goosed ]]; then
|
||||
echo "goosed dependency check failed: missing goosed binary under /Users/john/Project/tkmind_go/target" >&2
|
||||
portal_mcp_node="$(grep -E '^GOOSED_MCP_NODE_PATH=' /Users/john/Project/Memind/.env 2>/dev/null | tail -1 | cut -d= -f2- || true)"
|
||||
portal_mcp_server="$(grep -E '^GOOSED_MCP_SERVER_PATH=' /Users/john/Project/Memind/.env 2>/dev/null | tail -1 | cut -d= -f2- || true)"
|
||||
portal_mcp_node="${portal_mcp_node:-/opt/homebrew/opt/node@24/bin/node}"
|
||||
portal_mcp_server="${portal_mcp_server:-/Users/john/Project/Memind/mindspace-sandbox-mcp.mjs}"
|
||||
if [[ ! -x "${portal_mcp_node}" || ! -f "${portal_mcp_server}" ]]; then
|
||||
echo "goosed dependency check failed: Portal MCP paths are not valid: ${portal_mcp_node}, ${portal_mcp_server}" >&2
|
||||
missing=1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -14,6 +14,7 @@ fi
|
||||
|
||||
export NODE_ENV=production
|
||||
export H5_PORT="${H5_PORT:-8081}"
|
||||
export H5_HOST="${H5_HOST:-127.0.0.1}"
|
||||
export H5_PUBLIC_BASE_URL="https://m.tkmind.cn"
|
||||
export TKMIND_API_TARGETS="${TKMIND_API_TARGETS_OVERRIDE:-${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}}"
|
||||
export TKMIND_API_TARGET="${TKMIND_API_TARGET_OVERRIDE:-https://127.0.0.1:18006}"
|
||||
@@ -24,4 +25,7 @@ if [[ ! -x "${NODE_BIN}" ]]; then
|
||||
NODE_BIN="$(command -v node)"
|
||||
fi
|
||||
|
||||
export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-${NODE_BIN}}"
|
||||
export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-${ROOT}/mindspace-sandbox-mcp.mjs}"
|
||||
|
||||
exec "${NODE_BIN}" server.mjs
|
||||
|
||||
Reference in New Issue
Block a user