Add MindSpace page live edit, chat skills, and H5 deploy tooling.
Introduce page edit sessions with draft preview and patch API, chat skill picker, user memory profile, h5ApiBase resolution, voice WAV transport, and scripts for 105/g2 deployment and Plaza local dev. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
*.log
|
||||
MindSpace
|
||||
data/mindspace
|
||||
data/mindspace.bak*
|
||||
temp
|
||||
.git
|
||||
.DS_Store
|
||||
users
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.env
|
||||
.env.local
|
||||
.git/
|
||||
*.log
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Diagnose local Plaza setup and print fix hints.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import dns from 'node:dns';
|
||||
import { promisify } from 'node:util';
|
||||
import https from 'node:https';
|
||||
|
||||
const host = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn';
|
||||
const dnsPort = Number(process.env.PLAZA_LOCAL_DNS_PORT ?? 5533);
|
||||
const plazaPort = Number(process.env.PLAZA_PORT ?? 3001);
|
||||
const portalPort = Number(process.env.H5_PORT ?? 8081);
|
||||
|
||||
const lookup = promisify(dns.lookup);
|
||||
const resolve4 = promisify(dns.resolve4);
|
||||
|
||||
function tcpOpen(port, bind = '127.0.0.1') {
|
||||
return new Promise((resolve) => {
|
||||
const s = net.connect(port, bind, () => {
|
||||
s.end();
|
||||
resolve(true);
|
||||
});
|
||||
s.on('error', () => resolve(false));
|
||||
s.setTimeout(2000, () => {
|
||||
s.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function httpsPlaza() {
|
||||
return new Promise((resolve) => {
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: host,
|
||||
port: 443,
|
||||
path: '/plaza',
|
||||
rejectUnauthorized: false,
|
||||
timeout: 3000,
|
||||
},
|
||||
(res) => resolve({ ok: res.statusCode === 200, code: res.statusCode }),
|
||||
);
|
||||
req.on('error', (err) => resolve({ ok: false, error: err.message }));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve({ ok: false, error: 'timeout' });
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
const issues = [];
|
||||
const ok = [];
|
||||
|
||||
// hosts
|
||||
try {
|
||||
const hosts = fs.readFileSync('/etc/hosts', 'utf8');
|
||||
if (new RegExp(`127\\.0\\.0\\.1\\s+${host.replace('.', '\\.')}`).test(hosts)) ok.push('hosts 已指向 127.0.0.1');
|
||||
else issues.push(`执行:sudo pnpm setup:plaza-dns`);
|
||||
} catch {
|
||||
issues.push('无法读取 /etc/hosts');
|
||||
}
|
||||
|
||||
// resolver
|
||||
try {
|
||||
const body = fs.readFileSync(`/etc/resolver/${host}`, 'utf8');
|
||||
if (body.includes(`port ${dnsPort}`)) ok.push(`resolver -> 127.0.0.1:${dnsPort}`);
|
||||
else issues.push(`resolver 端口不对,执行:sudo pnpm setup:plaza-dns`);
|
||||
} catch {
|
||||
issues.push(`缺少 /etc/resolver/${host},执行:sudo pnpm setup:plaza-dns`);
|
||||
}
|
||||
|
||||
// services
|
||||
if (await tcpOpen(dnsPort)) ok.push(`本地 DNS :${dnsPort} 运行中`);
|
||||
else issues.push(`本地 DNS 未运行,执行:pnpm dev:plaza-dns(或 pnpm dev:plaza)`);
|
||||
|
||||
if (await tcpOpen(plazaPort)) ok.push(`Plaza Next.js :${plazaPort} 运行中`);
|
||||
else issues.push(`Plaza 未运行,执行:pnpm dev:plaza`);
|
||||
|
||||
if (await tcpOpen(portalPort)) ok.push(`Portal :${portalPort} 运行中`);
|
||||
else issues.push(`Portal 未运行(dev:plaza 会自动启动)`);
|
||||
|
||||
if (await tcpOpen(443)) ok.push('HTTPS 代理 :443 运行中');
|
||||
else issues.push('HTTPS 代理未运行,执行:sudo pnpm dev:plaza-proxy');
|
||||
|
||||
// DNS resolution
|
||||
const looked = await lookup(host, { all: true });
|
||||
const addrs = looked.map((x) => x.address);
|
||||
if (addrs.every((a) => a === '127.0.0.1' || a.startsWith('::ffff:127.'))) {
|
||||
ok.push(`系统 DNS lookup -> ${addrs.join(', ')}`);
|
||||
} else {
|
||||
issues.push(`系统 DNS 仍含非本地地址:${addrs.join(', ')}(Tailscale fake-ip)`);
|
||||
}
|
||||
|
||||
try {
|
||||
const r4 = await resolve4(host);
|
||||
if (r4.some((a) => a !== '127.0.0.1')) {
|
||||
issues.push(`resolve4 -> ${r4.join(', ')}(浏览器 Secure DNS 可能走线上 Cloudflare)`);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const httpsResult = await httpsPlaza();
|
||||
if (httpsResult.ok) ok.push(`终端 HTTPS ${host}/plaza -> ${httpsResult.code}`);
|
||||
else issues.push(`终端 HTTPS 失败:${httpsResult.error ?? httpsResult.code}`);
|
||||
|
||||
console.log('\n=== Plaza 本地诊断 ===\n');
|
||||
for (const line of ok) console.log(`✓ ${line}`);
|
||||
for (const line of issues) console.log(`✗ ${line}`);
|
||||
|
||||
console.log('\n--- 浏览器仍失败?---');
|
||||
console.log('Chrome/Cursor 内置浏览器默认走「安全 DNS」,会绕过 /etc/hosts 连到线上 Cloudflare。');
|
||||
console.log('');
|
||||
console.log('解决:');
|
||||
console.log(' 1. pnpm open:plaza # 用 Chrome 强制解析到本机');
|
||||
console.log(' 2. 或关闭浏览器「安全 DNS / Secure DNS」后刷新');
|
||||
console.log(' 3. 或临时访问 http://127.0.0.1:3001/plaza');
|
||||
console.log('');
|
||||
|
||||
process.exit(issues.length ? 1 : 0);
|
||||
Executable
+165
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
# Plaza 独立部署 → 105(与 Memind H5 解耦,仅共享同机 API)
|
||||
#
|
||||
# 架构:
|
||||
# plaza.tkmind.cn
|
||||
# /plaza、/_next → goose-plaza-web :3002 (/root/plaza/web)
|
||||
# /api、/auth、/u → goose-h5 :8080 (/root/tkmind_go/ui/h5)
|
||||
#
|
||||
# 用法:
|
||||
# pnpm deploy:plaza-105
|
||||
# pnpm deploy:plaza-105 -- --skip-build
|
||||
# pnpm deploy:plaza-105 -- --no-restart
|
||||
#
|
||||
# 前置:
|
||||
# 1. DNS: plaza.tkmind.cn A → 120.26.184.105
|
||||
# 2. H5 已部署:pnpm deploy:105(提供 Plaza API + 登录 + 发布页)
|
||||
# 3. 可选:cp deploy/plaza-105/plaza-105.env.example deploy/plaza-105/plaza-105.env
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
DEPLOY_DIR="${ROOT}/deploy/plaza-105"
|
||||
DEPLOY_ENV="${DEPLOY_DIR}/plaza-105.env"
|
||||
EXCLUDE="${ROOT}/scripts/.rsync-exclude-plaza-web"
|
||||
|
||||
PLAZA_APP_DIR="${PLAZA_APP_DIR:-${ROOT}/../tkmind_go/ui/plaza}"
|
||||
PLAZA_DEPLOY_HOST="${PLAZA_DEPLOY_HOST:-root@120.26.184.105}"
|
||||
PLAZA_REMOTE_DIR="${PLAZA_REMOTE_DIR:-/root/plaza/web}"
|
||||
PLAZA_PROD_URL="${PLAZA_PROD_URL:-https://plaza.tkmind.cn}"
|
||||
PLAZA_PORT="${PLAZA_PORT:-3002}"
|
||||
PLAZA_SYSTEMD_SERVICE="${PLAZA_SYSTEMD_SERVICE:-goose-plaza-web}"
|
||||
H5_SYSTEMD_SERVICE="${H5_SYSTEMD_SERVICE:-goose-h5}"
|
||||
MINDSPACE_PUBLIC_BASE="${MINDSPACE_PUBLIC_BASE:-https://g2.tkmind.cn}"
|
||||
|
||||
SKIP_BUILD=0
|
||||
NO_RESTART=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-build) SKIP_BUILD=1 ;;
|
||||
--no-restart) NO_RESTART=1 ;;
|
||||
-h|--help)
|
||||
sed -n '2,18p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "未知参数: $arg(可用 --skip-build / --no-restart)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -f "${DEPLOY_ENV}" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "${DEPLOY_ENV}"
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [[ ! -f "${PLAZA_APP_DIR}/package.json" ]]; then
|
||||
echo "错误: 未找到 Plaza Next.js:${PLAZA_APP_DIR}" >&2
|
||||
echo "请设置 PLAZA_APP_DIR 或确认 tkmind_go/ui/plaza 存在" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ssh_cmd() {
|
||||
ssh -o ConnectTimeout=15 -o BatchMode=yes "${PLAZA_DEPLOY_HOST}" "$@"
|
||||
}
|
||||
|
||||
write_remote_env() {
|
||||
ssh_cmd "mkdir -p '${PLAZA_REMOTE_DIR}' && cat > '${PLAZA_REMOTE_DIR}/.env' <<EOF
|
||||
PLAZA_API_PROXY=http://127.0.0.1:8080
|
||||
PLAZA_API_BASE=http://127.0.0.1:8080
|
||||
NEXT_PUBLIC_API_BASE=${PLAZA_PROD_URL}
|
||||
NEXT_PUBLIC_SITE_BASE=${PLAZA_PROD_URL}
|
||||
NEXT_PUBLIC_MINDSPACE_BASE=${MINDSPACE_PUBLIC_BASE}
|
||||
NEXT_PUBLIC_PLAZA_BASE=/plaza
|
||||
EOF"
|
||||
}
|
||||
|
||||
install_nginx() {
|
||||
echo "==> 安装 Nginx 配置..."
|
||||
ssh_cmd "mkdir -p /var/www/certbot"
|
||||
|
||||
if ssh_cmd "test -f /etc/letsencrypt/live/plaza.tkmind.cn/fullchain.pem"; then
|
||||
scp "${DEPLOY_DIR}/plaza.tkmind.cn.nginx.conf" \
|
||||
"${PLAZA_DEPLOY_HOST}:/etc/nginx/conf.d/plaza.tkmind.cn.conf"
|
||||
ssh_cmd "nginx -t && systemctl reload nginx"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "==> 首次部署:启用 HTTP bootstrap..."
|
||||
scp "${DEPLOY_DIR}/plaza.tkmind.cn.bootstrap.nginx.conf" \
|
||||
"${PLAZA_DEPLOY_HOST}:/etc/nginx/conf.d/plaza.tkmind.cn.conf"
|
||||
ssh_cmd "nginx -t && systemctl reload nginx"
|
||||
|
||||
echo "==> 申请 SSL 证书..."
|
||||
ssh_cmd "certbot certonly --webroot -w /var/www/certbot -d plaza.tkmind.cn \
|
||||
--non-interactive --agree-tos -m admin@tkmind.cn || true"
|
||||
|
||||
if ssh_cmd "test -f /etc/letsencrypt/live/plaza.tkmind.cn/fullchain.pem"; then
|
||||
scp "${DEPLOY_DIR}/plaza.tkmind.cn.nginx.conf" \
|
||||
"${PLAZA_DEPLOY_HOST}:/etc/nginx/conf.d/plaza.tkmind.cn.conf"
|
||||
else
|
||||
echo "⚠ 证书未签发,暂保留 HTTP bootstrap"
|
||||
fi
|
||||
|
||||
ssh_cmd "nginx -t && systemctl reload nginx"
|
||||
}
|
||||
|
||||
install_systemd() {
|
||||
echo "==> 安装 systemd (${PLAZA_SYSTEMD_SERVICE})..."
|
||||
scp "${DEPLOY_DIR}/goose-plaza-web.service" \
|
||||
"${PLAZA_DEPLOY_HOST}:/etc/systemd/system/${PLAZA_SYSTEMD_SERVICE}.service"
|
||||
ssh_cmd "systemctl daemon-reload && systemctl enable '${PLAZA_SYSTEMD_SERVICE}'"
|
||||
}
|
||||
|
||||
echo "======================================"
|
||||
echo "Plaza 独立部署 → 105"
|
||||
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "前端: ${PLAZA_APP_DIR}"
|
||||
echo "远端: ${PLAZA_DEPLOY_HOST}:${PLAZA_REMOTE_DIR}"
|
||||
echo "公网: ${PLAZA_PROD_URL}"
|
||||
echo "API: goose-h5 @ :8080(需已 deploy:105)"
|
||||
echo "======================================"
|
||||
|
||||
echo "==> rsync Plaza Next.js..."
|
||||
rsync -az --delete --exclude-from="${EXCLUDE}" \
|
||||
"${PLAZA_APP_DIR}/" \
|
||||
"${PLAZA_DEPLOY_HOST}:${PLAZA_REMOTE_DIR}/"
|
||||
|
||||
write_remote_env
|
||||
|
||||
if [[ "${SKIP_BUILD}" -eq 0 ]]; then
|
||||
echo "==> 远端 npm install && build..."
|
||||
ssh_cmd "cd '${PLAZA_REMOTE_DIR}' && npm install && npm run build"
|
||||
fi
|
||||
|
||||
install_systemd
|
||||
install_nginx
|
||||
|
||||
if [[ "${NO_RESTART}" -eq 0 ]]; then
|
||||
echo "==> 重启服务..."
|
||||
ssh_cmd "systemctl is-active --quiet '${H5_SYSTEMD_SERVICE}' || {
|
||||
echo '⚠ ${H5_SYSTEMD_SERVICE} 未运行,Plaza API 不可用。请先 pnpm deploy:105' >&2
|
||||
exit 1
|
||||
}"
|
||||
ssh_cmd "systemctl restart '${PLAZA_SYSTEMD_SERVICE}'"
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== 健康检查 ==="
|
||||
ssh_cmd "systemctl is-active '${PLAZA_SYSTEMD_SERVICE}' && echo '${PLAZA_SYSTEMD_SERVICE}: active'"
|
||||
ssh_cmd "curl -sf 'http://127.0.0.1:8080/api/status' && echo ' ← plaza-api (goose-h5) ok'" || {
|
||||
echo "✗ goose-h5 API 不可用,请执行 pnpm deploy:105" >&2
|
||||
exit 1
|
||||
}
|
||||
ssh_cmd "curl -s -o /dev/null -w 'plaza-web local → %{http_code}\n' 'http://127.0.0.1:${PLAZA_PORT}/plaza'" || true
|
||||
curl -s --max-time 20 -o /dev/null -w "${PLAZA_PROD_URL}/plaza → %{http_code}\n" "${PLAZA_PROD_URL}/plaza" || true
|
||||
|
||||
echo ""
|
||||
echo "✅ Plaza 前端已独立部署"
|
||||
echo " 首页: ${PLAZA_PROD_URL}/plaza"
|
||||
echo " 目录: ${PLAZA_REMOTE_DIR}"
|
||||
echo " API: 仍由 goose-h5 提供(pnpm deploy:105 同步)"
|
||||
+17
-2
@@ -84,6 +84,7 @@ async function waitFor(check, label, retries = 80) {
|
||||
|
||||
let portal;
|
||||
let plaza;
|
||||
let dnsServer;
|
||||
let stopping = false;
|
||||
|
||||
function spawnChild(command, args, label, cwd = root, extraEnv = {}) {
|
||||
@@ -107,6 +108,7 @@ function shutdown(code = 0) {
|
||||
stopping = true;
|
||||
portal?.kill('SIGTERM');
|
||||
plaza?.kill('SIGTERM');
|
||||
dnsServer?.kill('SIGTERM');
|
||||
setTimeout(() => process.exit(code), 300);
|
||||
}
|
||||
|
||||
@@ -118,9 +120,18 @@ if (!fs.existsSync(path.join(plazaDir, 'package.json'))) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!plazaHostConfigured(plazaHost)) {
|
||||
function plazaResolverConfigured(host) {
|
||||
try {
|
||||
const body = fs.readFileSync(`/etc/resolver/${host}`, 'utf8');
|
||||
return body.includes('tkmind-plaza-local');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!plazaHostConfigured(plazaHost) || !plazaResolverConfigured(plazaHost)) {
|
||||
console.warn('');
|
||||
console.warn(`⚠ ${plazaHost} 尚未写入 /etc/hosts`);
|
||||
console.warn(`⚠ ${plazaHost} 本地 DNS 未完整配置(Tailscale/Clash 会劫持解析)`);
|
||||
console.warn(' 请执行:sudo pnpm setup:plaza-dns');
|
||||
console.warn('');
|
||||
}
|
||||
@@ -144,8 +155,12 @@ console.log(` 公开地址 ${plazaPublicBase}/plaza`);
|
||||
console.log(` 内部端口 ${plazaPort}`);
|
||||
|
||||
freePort(plazaPort);
|
||||
freePort(Number(process.env.PLAZA_LOCAL_DNS_PORT ?? 5533));
|
||||
|
||||
try {
|
||||
console.log('==> 启动 Plaza 本地 DNS(覆盖 Tailscale fake-ip)');
|
||||
dnsServer = spawnChild('node', ['scripts/plaza-local-dns-server.mjs'], 'plaza-dns');
|
||||
|
||||
if (!(await portOpen(portalPort))) {
|
||||
console.log(`==> 启动 Portal @ ${portalUrl}`);
|
||||
portal = spawnChild('node', ['server.mjs'], 'portal');
|
||||
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# 正向 SSH 隧道:本机 18080 → 105 goose-h5 :8080(g2 负载均衡 upstream)
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${G2_TUNNEL_HOST:-ssh105}"
|
||||
LOCAL_PORT="${G2_TUNNEL_LOCAL_PORT:-18080}"
|
||||
REMOTE_HOST="${G2_TUNNEL_REMOTE_HOST:-127.0.0.1}"
|
||||
REMOTE_PORT="${G2_TUNNEL_REMOTE_PORT:-8080}"
|
||||
|
||||
exec ssh -N \
|
||||
-o ServerAliveInterval=30 \
|
||||
-o ServerAliveCountMax=3 \
|
||||
-o ExitOnForwardFailure=yes \
|
||||
-L "127.0.0.1:${LOCAL_PORT}:${REMOTE_HOST}:${REMOTE_PORT}" \
|
||||
"${HOST}"
|
||||
@@ -0,0 +1,10 @@
|
||||
# g2.tkmind.cn 本地发布:仅本机 Mac H5 (:8081),不经过 105
|
||||
:8090 {
|
||||
reverse_proxy 127.0.0.1:8081 {
|
||||
flush_interval -1
|
||||
transport http {
|
||||
read_timeout 0
|
||||
write_timeout 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# g2.tkmind.cn 本地发布:仅本机 Mac H5 (:8081),不经过 105
|
||||
:8090 {
|
||||
reverse_proxy 127.0.0.1:8081 {
|
||||
flush_interval -1
|
||||
transport http {
|
||||
read_timeout 0
|
||||
write_timeout 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# g2.tkmind.cn 本地发布:仅本机 Mac H5 (:8081),不经过 105
|
||||
:8090 {
|
||||
reverse_proxy 127.0.0.1:8081 {
|
||||
flush_interval -1
|
||||
transport http {
|
||||
read_timeout 0
|
||||
write_timeout 0
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# 安装 g2.tkmind.cn 50/50 负载均衡:Caddy :8090 + SSH 隧道 + 更新 cloudflared
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
TUNNEL="$ROOT/scripts/g2-h5-tunnel.sh"
|
||||
CADDYFILE="$ROOT/scripts/g2-lb.Caddyfile"
|
||||
CF_CONFIG="/Users/john/Project/ollama/cloudflare/config.yml"
|
||||
TUNNEL_PLIST="$HOME/Library/LaunchAgents/cn.tkmind.g2-h5-tunnel.plist"
|
||||
LB_PLIST="$HOME/Library/LaunchAgents/cn.tkmind.g2-lb.plist"
|
||||
LOG_DIR="$HOME/Library/Logs"
|
||||
|
||||
chmod +x "$TUNNEL" "$(dirname "$0")/install-g2-lb.sh"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
cat >"$TUNNEL_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>cn.tkmind.g2-h5-tunnel</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>${TUNNEL}</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${LOG_DIR}/g2-h5-tunnel.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${LOG_DIR}/g2-h5-tunnel.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
cat >"$LB_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>cn.tkmind.g2-lb</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/opt/homebrew/bin/caddy</string>
|
||||
<string>run</string>
|
||||
<string>--config</string>
|
||||
<string>${CADDYFILE}</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${LOG_DIR}/g2-lb.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${LOG_DIR}/g2-lb.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
if grep -q '127.0.0.1:8090' "$CF_CONFIG"; then
|
||||
echo "cloudflared 已指向 :8090,跳过 config 修改"
|
||||
else
|
||||
sed -i '' 's|service: http://127.0.0.1:8081|service: http://127.0.0.1:8090|' "$CF_CONFIG"
|
||||
echo "已更新 $CF_CONFIG → g2 指向 :8090"
|
||||
fi
|
||||
|
||||
UID_NUM="$(id -u)"
|
||||
GUI="gui/${UID_NUM}"
|
||||
|
||||
launchctl bootout "$GUI/cn.tkmind.g2-h5-tunnel" 2>/dev/null || true
|
||||
launchctl bootstrap "$GUI" "$TUNNEL_PLIST"
|
||||
launchctl enable "$GUI/cn.tkmind.g2-h5-tunnel"
|
||||
launchctl kickstart -k "$GUI/cn.tkmind.g2-h5-tunnel"
|
||||
|
||||
launchctl bootout "$GUI/cn.tkmind.g2-lb" 2>/dev/null || true
|
||||
launchctl bootstrap "$GUI" "$LB_PLIST"
|
||||
launchctl enable "$GUI/cn.tkmind.g2-lb"
|
||||
launchctl kickstart -k "$GUI/cn.tkmind.g2-lb"
|
||||
|
||||
launchctl kickstart -k "$GUI/com.cloudflare.cloudflared"
|
||||
|
||||
sleep 2
|
||||
echo ""
|
||||
echo "=== g2 负载均衡状态 ==="
|
||||
curl -s -o /dev/null -w "本机 upstream :8081 → %{http_code}\n" http://127.0.0.1:8081/api/status || true
|
||||
curl -s -o /dev/null -w "105 upstream :18080 → %{http_code}\n" http://127.0.0.1:18080/api/status || true
|
||||
curl -s -o /dev/null -w "Caddy LB :8090 → %{http_code}\n" http://127.0.0.1:8090/api/status || true
|
||||
echo ""
|
||||
echo "日志: ~/Library/Logs/g2-h5-tunnel.log ~/Library/Logs/g2-lb.log"
|
||||
echo "外网验证: curl -sI https://g2.tkmind.cn/api/status"
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# 安装 LaunchAgent:Mac 开机/登录后自动建立 MindSpace 反向隧道
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PLIST="$HOME/Library/LaunchAgents/cn.tkmind.memind-tunnel.plist"
|
||||
TUNNEL="$ROOT/scripts/memind-mac-tunnel.sh"
|
||||
|
||||
chmod +x "$TUNNEL"
|
||||
|
||||
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>cn.tkmind.memind-tunnel</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>${TUNNEL}</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${HOME}/Library/Logs/memind-mac-tunnel.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${HOME}/Library/Logs/memind-mac-tunnel.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
launchctl bootout "gui/$(id -u)/cn.tkmind.memind-tunnel" 2>/dev/null || true
|
||||
launchctl bootstrap "gui/$(id -u)" "$PLIST"
|
||||
launchctl enable "gui/$(id -u)/cn.tkmind.memind-tunnel"
|
||||
launchctl kickstart -k "gui/$(id -u)/cn.tkmind.memind-tunnel"
|
||||
|
||||
echo "已安装 MindSpace 反向隧道 LaunchAgent"
|
||||
echo "日志: ~/Library/Logs/memind-mac-tunnel.log"
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# 反向 SSH 隧道:让 105 通过 127.0.0.1:19999 访问本机 SSH(MindSpace 共享挂载依赖)
|
||||
set -euo pipefail
|
||||
|
||||
HOST="${MEMIND_TUNNEL_HOST:-ssh105}"
|
||||
LOCAL_PORT="${MEMIND_TUNNEL_LOCAL_PORT:-22}"
|
||||
REMOTE_PORT="${MEMIND_TUNNEL_REMOTE_PORT:-19999}"
|
||||
|
||||
exec ssh -N \
|
||||
-o ServerAliveInterval=30 \
|
||||
-o ServerAliveCountMax=3 \
|
||||
-o ExitOnForwardFailure=yes \
|
||||
-R "127.0.0.1:${REMOTE_PORT}:127.0.0.1:${LOCAL_PORT}" \
|
||||
"${HOST}"
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Open plaza.tkmind.cn in a browser with local host mapping.
|
||||
* Chromium Secure DNS bypasses /etc/hosts — host-resolver-rules fixes that.
|
||||
*
|
||||
* Usage: node scripts/open-plaza-local.mjs
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const host = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn';
|
||||
const url = (process.env.PLAZA_PUBLIC_BASE ?? `https://${host}`).replace(/\/$/, '') + '/plaza';
|
||||
const rules = `MAP ${host} 127.0.0.1`;
|
||||
|
||||
const attempts = [
|
||||
{
|
||||
label: 'Google Chrome',
|
||||
cmd: 'open',
|
||||
args: ['-na', 'Google Chrome', '--args', `--host-resolver-rules=${rules}`, url],
|
||||
},
|
||||
{
|
||||
label: 'Microsoft Edge',
|
||||
cmd: 'open',
|
||||
args: ['-na', 'Microsoft Edge', '--args', `--host-resolver-rules=${rules}`, url],
|
||||
},
|
||||
{
|
||||
label: 'Safari',
|
||||
cmd: 'open',
|
||||
args: ['-a', 'Safari', url],
|
||||
},
|
||||
];
|
||||
|
||||
for (const attempt of attempts) {
|
||||
const result = spawnSync(attempt.cmd, attempt.args, { stdio: 'ignore' });
|
||||
if (result.status === 0) {
|
||||
console.log(`已在 ${attempt.label} 打开:${url}`);
|
||||
if (attempt.label !== 'Safari') {
|
||||
console.log('(已通过 --host-resolver-rules 强制解析到 127.0.0.1)');
|
||||
}
|
||||
console.log('若提示证书不受信任,选择「继续访问」即可。');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('未找到 Chrome/Edge/Safari。请手动:');
|
||||
console.log(` 1. 关闭浏览器「安全 DNS / Secure DNS」`);
|
||||
console.log(` 2. 打开 ${url}`);
|
||||
console.log(` 3. 或访问 http://127.0.0.1:3001/plaza`);
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Minimal DNS server for plaza.tkmind.cn -> 127.0.0.1
|
||||
* Works with /etc/resolver/plaza.tkmind.cn (macOS per-domain DNS override).
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/plaza-local-dns-server.mjs
|
||||
*/
|
||||
import dgram from 'node:dgram';
|
||||
|
||||
const host = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn';
|
||||
const ip = (process.env.PLAZA_LOCAL_IP ?? '127.0.0.1').split('.').map(Number);
|
||||
const port = Number(process.env.PLAZA_LOCAL_DNS_PORT ?? 5533);
|
||||
const bind = process.env.PLAZA_LOCAL_DNS_BIND ?? '127.0.0.1';
|
||||
|
||||
function encodeName(name) {
|
||||
const parts = name.split('.').filter(Boolean);
|
||||
return Buffer.concat([
|
||||
...parts.map((part) => Buffer.concat([Buffer.from([part.length]), Buffer.from(part, 'ascii')])),
|
||||
Buffer.from([0]),
|
||||
]);
|
||||
}
|
||||
|
||||
const hostEncoded = encodeName(host);
|
||||
|
||||
function readQuestionName(msg, offset) {
|
||||
const labels = [];
|
||||
let pos = offset;
|
||||
while (pos < msg.length) {
|
||||
const len = msg[pos];
|
||||
if (len === 0) {
|
||||
pos += 1;
|
||||
break;
|
||||
}
|
||||
labels.push(msg.subarray(pos + 1, pos + 1 + len).toString('ascii'));
|
||||
pos += 1 + len;
|
||||
}
|
||||
return { name: labels.join('.'), next: pos };
|
||||
}
|
||||
|
||||
function questionMatches(name) {
|
||||
return name === host || name === `${host}.`;
|
||||
}
|
||||
|
||||
const server = dgram.createSocket('udp4');
|
||||
|
||||
server.on('message', (msg, rinfo) => {
|
||||
if (msg.length < 12) return;
|
||||
|
||||
const qdCount = msg.readUInt16BE(4);
|
||||
if (qdCount !== 1) return;
|
||||
|
||||
const question = readQuestionName(msg, 12);
|
||||
const qtype = msg.readUInt16BE(question.next);
|
||||
const qclass = msg.readUInt16BE(question.next + 2);
|
||||
const questionEnd = question.next + 4;
|
||||
|
||||
if (!questionMatches(question.name) || (qtype !== 1 && qtype !== 28) || qclass !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const questionSection = msg.subarray(12, questionEnd);
|
||||
const ttl = Buffer.from([0x00, 0x00, 0x00, 0x3c]); // 60s
|
||||
|
||||
let answer;
|
||||
if (qtype === 1) {
|
||||
answer = Buffer.concat([
|
||||
Buffer.from([0xc0, 0x0c]), // pointer to question name
|
||||
Buffer.from([0x00, 0x01, 0x00, 0x01]), // A, IN
|
||||
ttl,
|
||||
Buffer.from([0x00, 0x04]),
|
||||
Buffer.from(ip),
|
||||
]);
|
||||
} else {
|
||||
// AAAA for ::ffff:127.0.0.1 so browsers don't prefer Tailscale fake-ip v6
|
||||
answer = Buffer.concat([
|
||||
Buffer.from([0xc0, 0x0c]),
|
||||
Buffer.from([0x00, 0x1c, 0x00, 0x01]), // AAAA, IN
|
||||
ttl,
|
||||
Buffer.from([0x00, 0x10]),
|
||||
Buffer.from([
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ip[0], ip[1], ip[2], ip[3],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
const header = Buffer.alloc(12);
|
||||
header.writeUInt16BE(msg.readUInt16BE(0), 0); // ID
|
||||
header.writeUInt16BE(0x8180, 2); // QR=1, AA=1, RD supported
|
||||
header.writeUInt16BE(1, 4); // QDCOUNT
|
||||
header.writeUInt16BE(1, 6); // ANCOUNT
|
||||
header.writeUInt16BE(0, 8);
|
||||
header.writeUInt16BE(0, 10);
|
||||
|
||||
const response = Buffer.concat([header, questionSection, answer]);
|
||||
server.send(response, rinfo.port, rinfo.address);
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`Plaza DNS 端口 ${port} 已被占用`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
server.bind(port, bind, () => {
|
||||
console.log(`Plaza DNS ${host} -> ${ip.join('.')} @ ${bind}:${port}`);
|
||||
});
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.on(signal, () => {
|
||||
server.close(() => process.exit(0));
|
||||
});
|
||||
}
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# 在 105 上挂载本机 MindSpace(需 Mac 反向隧道 :19999 已建立)
|
||||
set -euo pipefail
|
||||
|
||||
H5_DIR="${H5_DIR:-/root/tkmind_go/ui/h5}"
|
||||
MOUNT_ROOT="${MOUNT_ROOT:-/mnt/memind-shared}"
|
||||
TUNNEL_PORT="${MEMIND_TUNNEL_REMOTE_PORT:-19999}"
|
||||
|
||||
mkdir -p "${MOUNT_ROOT}/MindSpace" "${MOUNT_ROOT}/data/mindspace"
|
||||
grep -q '^user_allow_other' /etc/fuse.conf 2>/dev/null || echo user_allow_other >> /etc/fuse.conf
|
||||
|
||||
if ! command -v rclone >/dev/null; then
|
||||
cd /tmp
|
||||
curl -fsSL -o rclone.zip https://downloads.rclone.org/rclone-current-linux-amd64.zip
|
||||
unzip -o rclone.zip
|
||||
cp rclone-*-linux-amd64/rclone /usr/local/bin/
|
||||
chmod +x /usr/local/bin/rclone
|
||||
fi
|
||||
|
||||
mkdir -p /root/.config/rclone
|
||||
cat >/root/.config/rclone/rclone.conf <<'EOF'
|
||||
[memind-mac]
|
||||
type = sftp
|
||||
host = 127.0.0.1
|
||||
port = 19999
|
||||
user = john
|
||||
key_file = /root/.ssh/id_ed25519
|
||||
shell_type = unix
|
||||
EOF
|
||||
|
||||
ssh-keyscan -p "${TUNNEL_PORT}" 127.0.0.1 >> /root/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if ss -tln | grep -q ":${TUNNEL_PORT} "; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
if ! ss -tln | grep -q ":${TUNNEL_PORT} "; then
|
||||
echo "错误: 本机反向隧道 ${TUNNEL_PORT} 未就绪,请先在 Mac 运行 scripts/install-memind-mac-tunnel.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rclone lsd memind-mac:/Users/john/Project/Memind/MindSpace >/dev/null
|
||||
|
||||
if [[ -d "${H5_DIR}/MindSpace" && ! -L "${H5_DIR}/MindSpace" ]]; then
|
||||
mv "${H5_DIR}/MindSpace" "${H5_DIR}/MindSpace.bak.local-$(date +%Y%m%d)"
|
||||
fi
|
||||
if [[ -d "${H5_DIR}/data/mindspace" && ! -L "${H5_DIR}/data/mindspace" ]]; then
|
||||
mv "${H5_DIR}/data/mindspace" "${H5_DIR}/data/mindspace.bak.local-$(date +%Y%m%d)"
|
||||
fi
|
||||
|
||||
cat >/etc/systemd/system/memind-shared-mindspace.mount.service <<EOF
|
||||
[Unit]
|
||||
Description=Rclone mount Mac MindSpace publish workspace
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStartPre=/bin/bash -c 'until ss -tln | grep -q ":${TUNNEL_PORT} "; do sleep 2; done'
|
||||
ExecStartPre=/bin/bash -c 'fusermount -uz ${MOUNT_ROOT}/MindSpace 2>/dev/null || true; mkdir -p ${MOUNT_ROOT}/MindSpace'
|
||||
ExecStart=/usr/local/bin/rclone mount memind-mac:/Users/john/Project/Memind/MindSpace ${MOUNT_ROOT}/MindSpace --config /root/.config/rclone/rclone.conf --allow-other --allow-non-empty --vfs-cache-mode writes --vfs-write-back 0 --dir-cache-time 10s --poll-interval 5s --attr-timeout 1s
|
||||
ExecStop=/bin/fusermount -uz ${MOUNT_ROOT}/MindSpace
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
cat >/etc/systemd/system/memind-shared-storage.mount.service <<EOF
|
||||
[Unit]
|
||||
Description=Rclone mount Mac MindSpace asset storage
|
||||
After=memind-shared-mindspace.mount.service
|
||||
Requires=memind-shared-mindspace.mount.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStartPre=/bin/bash -c 'fusermount -uz ${MOUNT_ROOT}/data/mindspace 2>/dev/null || true; mkdir -p ${MOUNT_ROOT}/data/mindspace'
|
||||
ExecStart=/usr/local/bin/rclone mount memind-mac:/Users/john/Project/Memind/data/mindspace ${MOUNT_ROOT}/data/mindspace --config /root/.config/rclone/rclone.conf --allow-other --allow-non-empty --vfs-cache-mode writes --vfs-write-back 0 --dir-cache-time 10s --poll-interval 5s --attr-timeout 1s
|
||||
ExecStop=/bin/fusermount -uz ${MOUNT_ROOT}/data/mindspace
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable memind-shared-mindspace.mount.service memind-shared-storage.mount.service
|
||||
systemctl restart memind-shared-mindspace.mount.service
|
||||
sleep 2
|
||||
systemctl restart memind-shared-storage.mount.service
|
||||
sleep 2
|
||||
|
||||
ln -sfn "${MOUNT_ROOT}/MindSpace" "${H5_DIR}/MindSpace"
|
||||
mkdir -p "${H5_DIR}/data"
|
||||
ln -sfn "${MOUNT_ROOT}/data/mindspace" "${H5_DIR}/data/mindspace"
|
||||
|
||||
ENV_FILE="${H5_DIR}/.env"
|
||||
if [[ -f "${ENV_FILE}" ]]; then
|
||||
if grep -q '^MINDSPACE_STORAGE_ROOT=' "${ENV_FILE}"; then
|
||||
sed -i "s|^MINDSPACE_STORAGE_ROOT=.*|MINDSPACE_STORAGE_ROOT=${MOUNT_ROOT}/data/mindspace|" "${ENV_FILE}"
|
||||
else
|
||||
echo "MINDSPACE_STORAGE_ROOT=${MOUNT_ROOT}/data/mindspace" >>"${ENV_FILE}"
|
||||
fi
|
||||
if grep -q '^MEMIND_SHARED_ROOT=' "${ENV_FILE}"; then
|
||||
sed -i "s|^MEMIND_SHARED_ROOT=.*|MEMIND_SHARED_ROOT=${MOUNT_ROOT}|" "${ENV_FILE}"
|
||||
else
|
||||
echo "MEMIND_SHARED_ROOT=${MOUNT_ROOT}" >>"${ENV_FILE}"
|
||||
fi
|
||||
fi
|
||||
|
||||
systemctl restart goose-h5 2>/dev/null || true
|
||||
|
||||
echo "✅ 105 MindSpace 已挂载本机共享目录 ${MOUNT_ROOT}"
|
||||
ls -la "${H5_DIR}/MindSpace" | head -5
|
||||
@@ -1,22 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Map plaza.tkmind.cn -> 127.0.0.1 for local Plaza dev.
|
||||
* Requires sudo to edit /etc/hosts.
|
||||
* Requires sudo to edit /etc/hosts and /etc/resolver.
|
||||
*
|
||||
* macOS + Tailscale/Clash fake-ip: /etc/hosts alone is not enough — browsers may
|
||||
* still resolve 198.18.x.x. Per-domain resolver forces local DNS server.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/setup-plaza-local-dns.mjs
|
||||
* node scripts/setup-plaza-local-dns.mjs --remove
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const HOST = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn';
|
||||
const IP = process.env.PLAZA_LOCAL_IP ?? '127.0.0.1';
|
||||
const DNS_PORT = Number(process.env.PLAZA_LOCAL_DNS_PORT ?? 5533);
|
||||
const MARKER = '# tkmind-plaza-local';
|
||||
const RESOLVER_MARKER = '# tkmind-plaza-local';
|
||||
const LINE = `${IP} ${HOST} ${MARKER}`;
|
||||
const remove = process.argv.includes('--remove');
|
||||
|
||||
const hostsPath = '/etc/hosts';
|
||||
const resolverDir = '/etc/resolver';
|
||||
const resolverPath = path.join(resolverDir, HOST);
|
||||
const resolverBody = `nameserver 127.0.0.1\nport ${DNS_PORT}\n${RESOLVER_MARKER}\n`;
|
||||
|
||||
function flushDnsCache() {
|
||||
try {
|
||||
execSync('dscacheutil -flushcache', { stdio: 'ignore' });
|
||||
execSync('killall -HUP mDNSResponder', { stdio: 'ignore' });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
function writeResolver() {
|
||||
fs.mkdirSync(resolverDir, { recursive: true });
|
||||
fs.writeFileSync(resolverPath, resolverBody, 'utf8');
|
||||
}
|
||||
|
||||
function removeResolver() {
|
||||
try {
|
||||
if (fs.existsSync(resolverPath)) fs.unlinkSync(resolverPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const current = fs.readFileSync(hostsPath, 'utf8');
|
||||
const lines = current.split('\n');
|
||||
const filtered = lines.filter(
|
||||
@@ -24,35 +56,61 @@ const filtered = lines.filter(
|
||||
);
|
||||
|
||||
if (remove) {
|
||||
if (filtered.length === lines.length) {
|
||||
console.log(`hosts 中未找到 ${HOST},无需移除`);
|
||||
process.exit(0);
|
||||
}
|
||||
const next = filtered.join('\n').replace(/\n?$/, '\n');
|
||||
execSync(`tee ${hostsPath}`, { input: next, stdio: ['pipe', 'inherit', 'inherit'] });
|
||||
console.log(`已移除 ${HOST} 本地解析`);
|
||||
if (filtered.length !== lines.length) {
|
||||
execSync(`tee ${hostsPath}`, { input: next, stdio: ['pipe', 'inherit', 'inherit'] });
|
||||
console.log(`已移除 hosts 中的 ${HOST}`);
|
||||
} else {
|
||||
console.log(`hosts 中未找到 ${HOST},无需移除`);
|
||||
}
|
||||
removeResolver();
|
||||
flushDnsCache();
|
||||
console.log('已移除 per-domain resolver');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (lines.some((line) => line.includes(MARKER) || new RegExp(`\\s${HOST.replace('.', '\\.')}(\\s|$)`).test(line))) {
|
||||
console.log(`${HOST} 已在 /etc/hosts 中指向本地`);
|
||||
const hostsReady = lines.some(
|
||||
(line) => line.includes(MARKER) || new RegExp(`\\s${HOST.replace('.', '\\.')}(\\s|$)`).test(line),
|
||||
);
|
||||
const resolverReady =
|
||||
fs.existsSync(resolverPath) && fs.readFileSync(resolverPath, 'utf8').trim() === resolverBody.trim();
|
||||
|
||||
if (hostsReady && resolverReady) {
|
||||
console.log(`${HOST} 本地解析已配置(hosts + resolver)`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const next = `${filtered.join('\n').replace(/\n?$/, '\n')}${LINE}\n`;
|
||||
if (process.getuid?.() !== 0) {
|
||||
console.log('需要 root 权限写入 /etc/hosts,请执行:');
|
||||
console.log('需要 root 权限写入 /etc/hosts 和 /etc/resolver,请执行:');
|
||||
console.log(` sudo node ${process.argv[1]}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!hostsReady) {
|
||||
const next = `${filtered.join('\n').replace(/\n?$/, '\n')}${LINE}\n`;
|
||||
try {
|
||||
execSync(`tee ${hostsPath}`, { input: next, stdio: ['pipe', 'inherit', 'inherit'] });
|
||||
console.log(`已添加 hosts:${LINE}`);
|
||||
} catch {
|
||||
console.error('写入 /etc/hosts 失败,请手动执行:');
|
||||
console.error(` sudo sh -c 'echo "${LINE}" >> /etc/hosts'`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.log(`${HOST} 已在 /etc/hosts 中指向本地`);
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`tee ${hostsPath}`, { input: next, stdio: ['pipe', 'inherit', 'inherit'] });
|
||||
console.log(`已添加:${LINE}`);
|
||||
console.log('下一步:');
|
||||
console.log(' sudo pnpm setup:plaza-local # hosts + TLS 证书');
|
||||
console.log(' sudo pnpm dev:plaza-proxy # HTTPS :443 代理');
|
||||
} catch {
|
||||
console.error('写入 /etc/hosts 失败,请手动执行:');
|
||||
console.error(` sudo sh -c 'echo "${LINE}" >> /etc/hosts'`);
|
||||
writeResolver();
|
||||
console.log(`已添加 resolver:${resolverPath} -> 127.0.0.1:${DNS_PORT}`);
|
||||
} catch (err) {
|
||||
console.error('写入 /etc/resolver 失败:', err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
flushDnsCache();
|
||||
console.log('已刷新 DNS 缓存');
|
||||
console.log('');
|
||||
console.log('下一步:');
|
||||
console.log(' pnpm dev:plaza # 含本地 DNS + Plaza 服务');
|
||||
console.log(' sudo pnpm dev:plaza-proxy # HTTPS :443 代理');
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Sync local MySQL (source) → RDS (target).
|
||||
* Keeps tables listed in KEEP_TARGET where RDS has richer production data.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/sync-local-db-to-rds.mjs
|
||||
* node scripts/sync-local-db-to-rds.mjs --dry-run
|
||||
*/
|
||||
import mysql from 'mysql2/promise';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
|
||||
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 sourceUrl =
|
||||
process.env.SYNC_SOURCE_DATABASE_URL ?? 'mysql://boot:password@127.0.0.1:3306/tkmind';
|
||||
const targetUrl =
|
||||
process.env.SYNC_TARGET_DATABASE_URL ??
|
||||
process.env.PLAZA_PROD_DATABASE_URL ??
|
||||
'mysql://boot:%40Abc888888@rm-uf6h1j53vtuxi78i90o.mysql.rds.aliyuncs.com:3306/goose';
|
||||
|
||||
/** RDS wins — do not overwrite from local. */
|
||||
const KEEP_TARGET = new Set(['h5_publication_views']);
|
||||
|
||||
function serializeRow(row) {
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
if (value instanceof Date) out[key] = value;
|
||||
else if (value !== null && typeof value === 'object') out[key] = JSON.stringify(value);
|
||||
else out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function copyTable(source, target, table) {
|
||||
const [rows] = await source.query(`SELECT * FROM \`${table}\``);
|
||||
if (rows.length === 0) {
|
||||
if (!dryRun) await target.query(`DELETE FROM \`${table}\``).catch(() => {});
|
||||
return 0;
|
||||
}
|
||||
if (dryRun) return rows.length;
|
||||
|
||||
await target.query('SET FOREIGN_KEY_CHECKS=0');
|
||||
await target.query(`DELETE FROM \`${table}\``);
|
||||
const batchSize = 200;
|
||||
let copied = 0;
|
||||
for (let i = 0; i < rows.length; i += batchSize) {
|
||||
const chunk = rows.slice(i, i + batchSize).map(serializeRow);
|
||||
const cols = Object.keys(chunk[0]);
|
||||
const placeholders = chunk.map(() => `(${cols.map(() => '?').join(', ')})`).join(', ');
|
||||
const values = chunk.flatMap((row) => cols.map((col) => row[col]));
|
||||
await target.query(
|
||||
`INSERT INTO \`${table}\` (${cols.map((c) => `\`${c}\``).join(', ')}) VALUES ${placeholders}`,
|
||||
values,
|
||||
);
|
||||
copied += chunk.length;
|
||||
}
|
||||
await target.query('SET FOREIGN_KEY_CHECKS=1');
|
||||
return copied;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const source = await mysql.createConnection(sourceUrl);
|
||||
const target = await mysql.createConnection(targetUrl);
|
||||
|
||||
const [localTables] = await source.query('SHOW TABLES');
|
||||
const [remoteTables] = await target.query('SHOW TABLES');
|
||||
const remoteSet = new Set(remoteTables.map((r) => Object.values(r)[0]));
|
||||
const tables = localTables
|
||||
.map((r) => Object.values(r)[0])
|
||||
.filter((t) => remoteSet.has(t))
|
||||
.sort();
|
||||
|
||||
console.log(`源库: ${sourceUrl.replace(/:[^:@/]+@/, ':***@')}`);
|
||||
console.log(`目标: ${targetUrl.replace(/:[^:@/]+@/, ':***@')}`);
|
||||
console.log(`保留 RDS 数据(不覆盖): ${[...KEEP_TARGET].join(', ') || '(无)'}`);
|
||||
console.log('');
|
||||
|
||||
const summary = [];
|
||||
for (const table of tables) {
|
||||
if (KEEP_TARGET.has(table)) {
|
||||
const [[r]] = await target.query(`SELECT COUNT(*) AS c FROM \`${table}\``);
|
||||
summary.push({ table, action: 'keep-rds', rows: Number(r.c) });
|
||||
continue;
|
||||
}
|
||||
const [[l]] = await source.query(`SELECT COUNT(*) AS c FROM \`${table}\``);
|
||||
const localCount = Number(l.c);
|
||||
const copied = await copyTable(source, target, table);
|
||||
summary.push({ table, action: dryRun ? 'would-sync' : 'synced', rows: copied, localCount });
|
||||
}
|
||||
|
||||
console.log('TABLE\tACTION\tROWS');
|
||||
for (const row of summary) {
|
||||
console.log(`${row.table}\t${row.action}\t${row.rows}`);
|
||||
}
|
||||
|
||||
await source.end();
|
||||
await target.end();
|
||||
console.log(dryRun ? '\n(dry-run,未写入)' : '\n✅ 同步完成');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# Memind 本地 → 105 全量同步(代码与 dist 一致;保留远端 .env 与 MindSpace 共享挂载)
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
EXCLUDE="${ROOT}/scripts/.rsync-exclude-105"
|
||||
HOST="${H5_DEPLOY_HOST:-root@120.26.184.105}"
|
||||
REMOTE="${H5_REMOTE_DIR:-/root/tkmind_go/ui/h5}"
|
||||
SERVICE="${H5_SYSTEMD_SERVICE:-goose-h5}"
|
||||
|
||||
echo "======================================"
|
||||
echo "Memind → 105 同步"
|
||||
echo "本地: ${ROOT}"
|
||||
echo "远端: ${HOST}:${REMOTE}"
|
||||
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "======================================"
|
||||
|
||||
echo "==> rsync 代码 (--delete)..."
|
||||
rsync -az --delete --exclude-from="${EXCLUDE}" "${ROOT}/" "${HOST}:${REMOTE}/"
|
||||
|
||||
echo "==> rsync dist..."
|
||||
rsync -az "${ROOT}/dist/" "${HOST}:${REMOTE}/dist/"
|
||||
|
||||
echo "==> 远端 npm install..."
|
||||
ssh -o BatchMode=yes "${HOST}" "cd '${REMOTE}' && npm install"
|
||||
|
||||
if [[ "${SKIP_BUILD:-0}" != 1 ]]; then
|
||||
echo "==> 远端 npm run build(失败不阻断,沿用已同步 dist)..."
|
||||
ssh -o BatchMode=yes "${HOST}" "cd '${REMOTE}' && npm run build" || echo "⚠️ 远端 vite build 失败,已使用 rsync 的 dist/"
|
||||
fi
|
||||
|
||||
if [[ "${NO_RESTART:-0}" != 1 ]]; then
|
||||
echo "==> 重启 ${SERVICE}..."
|
||||
ssh -o BatchMode=yes "${HOST}" "systemctl restart '${SERVICE}' && sleep 3 && systemctl is-active '${SERVICE}'"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== 校验 ==="
|
||||
for f in server.mjs user-publish.mjs user-space.mjs mindspace-page-edit-session.mjs package.json; do
|
||||
local_sum="$(md5 -q "${ROOT}/${f}" 2>/dev/null || md5sum "${ROOT}/${f}" | awk '{print $1}')"
|
||||
remote_sum="$(ssh -o BatchMode=yes "${HOST}" "md5sum '${REMOTE}/${f}' | awk '{print \$1}'")"
|
||||
if [[ "${local_sum}" == "${remote_sum}" ]]; then
|
||||
echo "✓ ${f}"
|
||||
else
|
||||
echo "✗ ${f} 不一致 (local=${local_sum} remote=${remote_sum})" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
ssh -o BatchMode=yes "${HOST}" "curl -sf 'http://127.0.0.1:8080/api/status' && echo ' ← h5 ok'" || {
|
||||
echo "✗ 105 H5 健康检查失败" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "✅ 本地与 105 已同步"
|
||||
Reference in New Issue
Block a user