Add smart ACK provider for WeChat MP replies

Replace fixed ackText with a rule-based AckProvider that picks
response templates by message type and intent (translate, summary,
rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync,
zero I/O, auto-falls back to config.ackText on any error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
john
2026-06-26 15:19:03 +08:00
parent 9ed4fd48d7
commit 9b4a25799f
162 changed files with 17276 additions and 2054 deletions
+24
View File
@@ -0,0 +1,24 @@
.git
.gitignore
.DS_Store
.env
.h5.pid
.plaza.pid
.claude
.local
.codex-backups
MindSpace
data
users
.tailscale
node_modules
ops/node_modules
dist
ops/dist
temp
logs
*.log
h5.log
h5-prod.log
server.mjs.bak-*
*.bak-20*
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
HOST="${STUDIO_HOST:-john@58.38.22.103}"
REMOTE_ROOT="${STUDIO_REMOTE_ROOT:-/Users/john/Project}"
say() {
printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*"
}
say "103 主机与运行目录"
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \
"hostname; printf 'REMOTE_ROOT=%s\n' '${REMOTE_ROOT}'; ls '${REMOTE_ROOT}' | sed -n '1,80p'"
say "103 运行服务"
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" \
"launchctl list | egrep 'memind|plaza|goose|adm' || true"
say "103 生产目录 git 概况"
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" '
for d in /Users/john/Project/Memind /Users/john/Project/memind_adm; do
echo "==$(basename "$d")=="
echo "branch=$(git -C "$d" rev-parse --abbrev-ref HEAD)"
echo "commit=$(git -C "$d" rev-parse --short HEAD)"
echo "dirty=$(git -C "$d" status --short | wc -l | tr -d " ")"
done'
say "103 数据库指向"
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" '
for f in /Users/john/Project/Memind/.env /Users/john/Project/memind_adm/.env; do
echo "==${f}=="
egrep "^(DATABASE_URL|MYSQL_HOST|MYSQL_PORT|MYSQL_DATABASE|H5_PORT|ADM_API_PORT)" "$f" || true
done'
say "本地与 103 HEAD 对比"
remote_memind="$(ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "cd /Users/john/Project/Memind && git rev-parse --short HEAD")"
remote_adm="$(ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "cd /Users/john/Project/memind_adm && git rev-parse --short HEAD")"
local_memind="$(git -C "$(cd "$(dirname "$0")/.." && pwd)" rev-parse --short HEAD)"
local_adm="$(git -C /Users/john/PycharmProjects/test/test-memindadm rev-parse --short HEAD)"
printf 'local_memind=%s remote_memind=%s\n' "${local_memind}" "${remote_memind}"
printf 'local_memindadm=%s remote_memindadm=%s\n' "${local_adm}" "${remote_adm}"
+70
View File
@@ -0,0 +1,70 @@
#!/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 { resolvePlazaCoverUrl } from '../plaza-posts.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const shouldWrite = process.argv.includes('--write');
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 pool = createDbPool();
const [rows] = await pool.query(
`SELECT pp.id, pp.title, pp.cover_url, pp.status, pr.public_url
FROM plaza_posts pp
JOIN h5_publish_records pr ON pr.id = pp.publication_id
WHERE pp.status IN ('published', 'pending_review', 'rejected')
ORDER BY pp.published_at DESC, pp.id DESC`,
);
const updates = rows
.map((row) => ({
id: row.id,
title: row.title,
from: String(row.cover_url ?? ''),
to: resolvePlazaCoverUrl(row.cover_url, row.public_url),
publicUrl: String(row.public_url ?? ''),
}))
.filter((row) => row.to && row.from !== row.to);
console.log(`${shouldWrite ? '写入模式' : 'Dry-run'}:共扫描 ${rows.length} 条帖子,命中 ${updates.length} 条待补封面。`);
for (const row of updates.slice(0, 20)) {
console.log(`- ${row.id} | ${row.title}`);
console.log(` ${row.from || '(empty)'} -> ${row.to}`);
}
if (updates.length > 20) {
console.log(`... 其余 ${updates.length - 20} 条省略`);
}
if (shouldWrite && updates.length > 0) {
const now = Date.now();
for (const row of updates) {
await pool.query(`UPDATE plaza_posts SET cover_url = ?, updated_at = ? WHERE id = ?`, [
row.to,
now,
row.id,
]);
}
console.log(`已更新 ${updates.length} 条帖子封面。`);
} else if (!shouldWrite) {
console.log('未写入数据库;如需正式回填,请执行: node scripts/backfill-plaza-cover-urls.mjs --write');
}
await pool.end();
+96
View File
@@ -0,0 +1,96 @@
#!/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 { defaultPlazaCoverUrl } from '../plaza-posts.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const shouldWrite = process.argv.includes('--write');
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 absolutize(url) {
const value = String(url ?? '').trim();
if (!value) return '';
if (/^https?:\/\//i.test(value)) return value;
return new URL(value, publicBaseUrl).toString();
}
loadEnvFile(path.join(root, '../../.env.local'));
loadEnvFile(path.join(root, '.env'));
const { resolvePublicBaseUrl } = await import('../user-publish.mjs');
const publicBaseUrl = resolvePublicBaseUrl();
const pool = createDbPool();
const [rows] = await pool.query(
`SELECT pp.id, pp.publication_id, pp.title, pp.cover_url, pp.status, pr.public_url, pr.status AS publication_status
FROM plaza_posts pp
JOIN h5_publish_records pr ON pr.id = pp.publication_id
WHERE pr.public_url IS NOT NULL
ORDER BY pp.published_at DESC, pp.id DESC`,
);
const updates = rows
.map((row) => {
const nextPublicUrl = absolutize(row.public_url);
const nextCoverUrl = row.cover_url
? absolutize(row.cover_url)
: absolutize(defaultPlazaCoverUrl(nextPublicUrl));
return {
id: row.id,
publicationId: row.publication_id,
title: row.title,
fromPublicUrl: String(row.public_url ?? ''),
toPublicUrl: nextPublicUrl,
fromCoverUrl: String(row.cover_url ?? ''),
toCoverUrl: nextCoverUrl,
status: row.status,
publicationStatus: row.publication_status,
};
})
.filter((row) => row.toPublicUrl && (row.fromPublicUrl !== row.toPublicUrl || row.fromCoverUrl !== row.toCoverUrl));
console.log(
`${shouldWrite ? '写入模式' : 'Dry-run'}:共扫描 ${rows.length} 条帖子,命中 ${updates.length} 条待补 public_url/cover_url。`,
);
console.log(`Public base: ${publicBaseUrl}`);
for (const row of updates.slice(0, 20)) {
console.log(`- ${row.id} | ${row.title}`);
console.log(` public_url: ${row.fromPublicUrl || '(empty)'} -> ${row.toPublicUrl}`);
console.log(` cover_url : ${row.fromCoverUrl || '(empty)'} -> ${row.toCoverUrl}`);
}
if (updates.length > 20) {
console.log(`... 其余 ${updates.length - 20} 条省略`);
}
if (shouldWrite && updates.length > 0) {
const now = Date.now();
for (const row of updates) {
await pool.query(
`UPDATE h5_publish_records pr
JOIN plaza_posts pp ON pp.publication_id = pr.id
SET pr.public_url = ?, pp.cover_url = ?, pr.updated_at = ?, pp.updated_at = ?
WHERE pr.id = ?`,
[row.toPublicUrl, row.toCoverUrl, now, now, row.publicationId],
);
}
console.log(`已更新 ${updates.length} 条帖子公开链接与封面。`);
} else if (!shouldWrite) {
console.log('未写入数据库;如需正式回填,请执行: node scripts/backfill-plaza-public-urls.mjs --write');
}
await pool.end();
+22 -6
View File
@@ -94,6 +94,26 @@ async function bundleServer() {
await run(esbuildBin, args);
}
async function bundleSandboxMcp() {
if (!(await exists(esbuildBin))) {
throw new Error(`未找到 esbuild: ${esbuildBin}`);
}
console.log('==> 打包 sandbox MCP 为单文件 runtime');
const args = [
'mindspace-sandbox-mcp.mjs',
'--bundle',
'--platform=node',
'--format=esm',
'--target=node24',
'--outfile=.runtime/portal/mindspace-sandbox-mcp.mjs',
'--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
];
for (const pkg of externalPackages) {
args.push(`--external:${pkg}`);
}
await run(esbuildBin, args);
}
async function copyRuntimeAssets() {
console.log('==> 拷贝运行时静态资源');
await remove(runtimeRoot);
@@ -111,11 +131,6 @@ async function copyRuntimeAssets() {
if (await exists(schemaFile)) {
await fs.copyFile(schemaFile, path.join(runtimeRoot, 'schema.sql'));
}
const sandboxMcpFile = path.join(root, 'mindspace-sandbox-mcp.mjs');
if (await exists(sandboxMcpFile)) {
await fs.copyFile(sandboxMcpFile, path.join(runtimeRoot, 'mindspace-sandbox-mcp.mjs'));
}
}
async function copyNodeModules() {
@@ -212,7 +227,7 @@ async function writeMetadata() {
' logs/',
'',
'Bundled alongside server.mjs (required for sandbox-fs MCP):',
' mindspace-sandbox-mcp.mjs',
' mindspace-sandbox-mcp.mjs (esbuild bundle; includes schedule-service deps)',
'',
'Key runtime differences must stay in .env, not in the artifact:',
' DATABASE_URL / MYSQL_*',
@@ -233,6 +248,7 @@ async function main() {
await buildFrontend();
await copyRuntimeAssets();
await bundleServer();
await bundleSandboxMcp();
await copyNodeModules();
await writeMetadata();
await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-portal-prod.sh'), 0o755);
+3 -3
View File
@@ -12,7 +12,7 @@
# pnpm deploy:plaza-105 -- --no-restart
#
# 前置:
# 1. DNS: plaza.tkmind.cn A → 120.26.184.105(公网入口;部署链路本身由 ssh105(Tailscale) 触达)
# 1. DNS: plaza.tkmind.cn A → 120.26.184.105(公网入口);运维/部署直接使用固定公网 IP
# 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
@@ -23,13 +23,13 @@ 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@ssh105}"
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}"
MINDSPACE_PUBLIC_BASE="${MINDSPACE_PUBLIC_BASE:-https://m.tkmind.cn}"
SKIP_BUILD=0
NO_RESTART=0
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env node
import { spawn, execSync } from 'node:child_process';
import fs from 'node:fs';
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 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'));
function freePort(port) {
try {
execSync(`lsof -ti TCP:${port} -sTCP:LISTEN | xargs kill -9`, { stdio: 'ignore' });
} catch {
// port already free
}
}
function spawnChild(command, args, label, cwd = root, extraEnv = {}) {
const child = spawn(command, args, {
cwd,
env: { ...process.env, ...extraEnv },
stdio: 'inherit',
});
child.on('exit', (code, signal) => {
if (signal) return;
if (code && code !== 0) {
console.error(`[${label}] exited with code ${code}`);
shutdown(code ?? 1);
}
});
return child;
}
let server;
let admin;
let ops;
let vite;
let stopping = false;
function shutdown(code = 0) {
if (stopping) return;
stopping = true;
server?.kill('SIGTERM');
admin?.kill('SIGTERM');
ops?.kill('SIGTERM');
vite?.kill('SIGTERM');
setTimeout(() => process.exit(code), 300);
}
process.on('SIGINT', () => shutdown(0));
process.on('SIGTERM', () => shutdown(0));
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,
};
console.log(`==> 清理端口 ${portalPort} / ${adminPort} / ${vitePort} / ${opsPort}...`);
freePort(portalPort);
freePort(adminPort);
freePort(vitePort);
freePort(opsPort);
await new Promise((resolve) => setTimeout(resolve, 500));
console.log('==> 启动 portal (server.mjs)...');
server = spawnChild('node', [path.join(root, 'server.mjs')], 'portal');
try {
await waitFor(portalUrl, async (url) => (await fetch(`${url}/auth/status`)).ok, 'Portal');
console.log(`==> Portal 就绪: ${portalUrl}`);
console.log('==> 启动 memind_adm (admin-server.mjs)...');
admin = spawnChild('node', [path.join(root, 'admin-server.mjs')], 'admin');
await waitFor(adminUrl, async (url) => (await fetch(`${url}/healthz`)).ok, 'Admin');
console.log(`==> memind_adm 就绪: ${adminUrl}`);
console.log(`==> 启动 Ops 后台 @ http://127.0.0.1:${opsPort}/ops/`);
ops = spawnChild('npm', ['run', 'dev'], 'ops', opsDir, opsEnv);
await waitFor(
`http://127.0.0.1:${opsPort}`,
async (url) => (await fetch(`${url}/ops/`)).ok,
'Ops',
40,
);
console.log(`==> Ops 就绪: http://127.0.0.1:${opsPort}/ops/`);
console.log(`==> 启动 Vite @ http://127.0.0.1:${vitePort}`);
vite = spawnChild('npx', ['vite'], 'vite', root, viteEnv);
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',
);
console.log(`==> Vite 就绪: http://127.0.0.1:${vitePort}`);
console.log('');
console.log('本地服务:');
console.log(` MindSpace UI http://127.0.0.1:${vitePort}/?preview=mindspace`);
console.log(` Ops 审核后台 http://127.0.0.1:${opsPort}/ops/`);
console.log(` API / Portal ${portalUrl}`);
console.log(` memind_adm ${adminUrl}`);
console.log('');
console.log('Plaza 已独立出去:请另开终端运行 pnpm dev:plaza 或 pnpm start:plaza');
} catch (err) {
console.error(err instanceof Error ? err.message : err);
shutdown(1);
}
+5 -2
View File
@@ -12,7 +12,10 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const plazaDir = process.env.PLAZA_APP_DIR ?? path.join(root, '../tkmind_go/ui/plaza');
const defaultPlazaDir = fs.existsSync(path.join(root, '../test-memindplaza/app/plaza/package.json'))
? path.join(root, '../test-memindplaza/app/plaza')
: path.join(root, '../tkmind_go/ui/plaza');
const plazaDir = process.env.PLAZA_APP_DIR ?? defaultPlazaDir;
const portalPort = Number(process.env.H5_PORT ?? 8081);
const plazaPort = Number(process.env.PLAZA_PORT ?? 3001);
const publicPort = Number(process.env.PLAZA_PUBLIC_PORT ?? 443);
@@ -116,7 +119,7 @@ process.on('SIGINT', () => shutdown(0));
process.on('SIGTERM', () => shutdown(0));
if (!fs.existsSync(path.join(plazaDir, 'package.json'))) {
console.error(`未找到 Plaza Next.js${plazaDir}`);
console.error(`未找到 Plaza Next.js${plazaDir}。请设置 PLAZA_APP_DIR 指向本地 Plaza 源码,例如 ../test-memindplaza/app/plaza`);
process.exit(1);
}
+7 -2
View File
@@ -1,11 +1,16 @@
#!/usr/bin/env node
// Compatibility entrypoint for the old full-stack startup.
// Prefer `pnpm dev` for the core app and `pnpm dev:plaza` for Plaza alone.
import { spawn, execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const plazaDir = process.env.PLAZA_APP_DIR ?? path.join(root, '../tkmind_go/ui/plaza');
const defaultPlazaDir = fs.existsSync(path.join(root, '../test-memindplaza/app/plaza/package.json'))
? path.join(root, '../test-memindplaza/app/plaza')
: path.join(root, '../tkmind_go/ui/plaza');
const plazaDir = process.env.PLAZA_APP_DIR ?? defaultPlazaDir;
const opsDir = path.join(root, 'ops');
const portalPort = Number(process.env.H5_PORT ?? 8081);
const vitePort = Number(process.env.VITE_PORT ?? 5173);
@@ -120,7 +125,7 @@ const opsEnv = {
function ensurePlazaApp() {
if (!fs.existsSync(path.join(plazaDir, 'package.json'))) {
throw new Error(`未找到 Plaza Next.js${plazaDir}`);
throw new Error(`未找到 Plaza Next.js${plazaDir}。请设置 PLAZA_APP_DIR 指向本地 Plaza 源码,例如 ../test-memindplaza/app/plaza`);
}
}
+45
View File
@@ -0,0 +1,45 @@
{
admin off
email admin@tkmind.cn
http_port 8090
https_port 8444
}
# m.tkmind.cn:公网入口转发到 Studio 本机 H5 (:8081)
# 证书:acme.sh DNS-01 + 阿里云 API,自动续期
m.tkmind.cn {
tls /Users/john/.acme.sh/m.tkmind.cn_ecc/fullchain.cer /Users/john/.acme.sh/m.tkmind.cn_ecc/m.tkmind.cn.key
reverse_proxy 127.0.0.1:8081 {
header_down +X-Memind-Upstream {http.reverse_proxy.upstream.hostport}
flush_interval -1
transport http {
read_timeout 0
write_timeout 0
}
}
}
plaza.tkmind.cn {
tls /Users/john/.acme.sh/plaza.tkmind.cn_ecc/fullchain.cer /Users/john/.acme.sh/plaza.tkmind.cn_ecc/plaza.tkmind.cn.key
reverse_proxy 127.0.0.1:3001 {
flush_interval -1
transport http {
read_timeout 0
write_timeout 0
}
}
}
gadm.tkmind.cn {
tls /Users/john/.acme.sh/gadm.tkmind.cn_ecc/fullchain.cer /Users/john/.acme.sh/gadm.tkmind.cn_ecc/gadm.tkmind.cn.key
reverse_proxy 127.0.0.1:5174 {
flush_interval -1
transport http {
read_timeout 0
write_timeout 0
}
}
}
+10
View File
@@ -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,22 @@
{
admin off
email cynell2015@gmail.com
http_port 8090
https_port 8444
}
# g2.tkmind.cn:公网入口转发到 Studio 本机 H5 (:8081)
g2.tkmind.cn {
reverse_proxy 127.0.0.1:8081 {
header_down +X-Memind-Upstream {http.reverse_proxy.upstream.hostport}
health_uri /api/status
health_interval 10s
health_timeout 5s
health_status 200
flush_interval -1
transport http {
read_timeout 0
write_timeout 0
}
}
}
+1 -1
View File
@@ -92,4 +92,4 @@ curl -s -o /dev/null -w "105 upstream :18080 → %{http_code}\n" http://127.0.0
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"
echo "外网验证: curl -sI https://m.tkmind.cn/api/status"
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
PLIST="$HOME/Library/LaunchAgents/cn.tkmind.goosed-monitor.plist"
LOG="$HOME/Library/Logs/goosed-monitor.log"
GUI="gui/$(id -u)"
mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs"
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.goosed-monitor</string>
<key>ProgramArguments</key>
<array>
<string>$NODE_BIN</string>
<string>$ROOT/scripts/monitor-goosed-fds.mjs</string>
</array>
<key>WorkingDirectory</key>
<string>$ROOT</string>
<key>StartInterval</key>
<integer>60</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>$LOG</string>
<key>StandardErrorPath</key>
<string>$LOG</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin</string>
<key>GOOSED_FD_WARN</key>
<string>180</string>
<key>GOOSED_FD_RESTART</key>
<string>3200</string>
<key>SANDBOX_MCP_MAX_TOTAL</key>
<string>80</string>
<key>SANDBOX_MCP_MAX_PER_ROOT</key>
<string>2</string>
</dict>
</dict>
</plist>
EOF
plutil -lint "$PLIST"
launchctl bootout "$GUI/cn.tkmind.goosed-monitor" 2>/dev/null || true
launchctl bootstrap "$GUI" "$PLIST"
launchctl enable "$GUI/cn.tkmind.goosed-monitor"
launchctl kickstart -k "$GUI/cn.tkmind.goosed-monitor"
echo "installed $PLIST"
echo "log: $LOG"
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# 安装 g2.tkmind.cn + plaza.tkmind.cn 生产 LaunchAgent(登录自启 + 崩溃自动重启)
# 安装 m.tkmind.cn + plaza.tkmind.cn 生产 LaunchAgent(登录自启 + 崩溃自动重启)
#
# 架构:
# cloudflared → g2 :8090 (Caddy) → Portal :8081
@@ -169,7 +169,7 @@ launchctl list | grep -E "cn.tkmind\.(memind-portal|plaza|g2-lb)|com.cloudflare.
curl -s -o /dev/null -w "Portal :8081 → %{http_code}\n" "http://127.0.0.1:8081/api/status" || true
curl -s -o /dev/null -w "g2 LB :8090 → %{http_code}\n" "http://127.0.0.1:8090/api/status" || true
curl -s -o /dev/null -w "Plaza :3001 → %{http_code}\n" "http://127.0.0.1:3001/plaza" || true
curl -s -o /dev/null -w "外网 g2 → %{http_code}\n" "https://g2.tkmind.cn/api/status" || true
curl -s -o /dev/null -w "外网 m → %{http_code}\n" "https://m.tkmind.cn/api/status" || true
curl -s -o /dev/null -w "外网 plaza → %{http_code}\n" "https://plaza.tkmind.cn/plaza" || true
echo ""
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createDbPool } from '../db.mjs';
import { buildExecutorLaunchPlan, 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 parseArgs(argv) {
if (argv[0] === '--help' || argv[0] === '-h') {
return { help: true, executor: '' };
}
const [executor, ...rest] = argv;
const out = {
executor: String(executor ?? '').trim(),
cwd: process.cwd(),
mode: 'headless',
instruction: '',
purpose: 'default',
};
for (let i = 0; i < rest.length; i += 1) {
const item = rest[i];
if (item === '--cwd') out.cwd = String(rest[++i] ?? out.cwd);
else if (item === '--mode') out.mode = String(rest[++i] ?? out.mode);
else if (item === '--instruction') out.instruction = String(rest[++i] ?? out.instruction);
else if (item === '--purpose') out.purpose = String(rest[++i] ?? out.purpose);
else if (item === '--help' || item === '-h') out.help = true;
}
return out;
}
function printHelp() {
console.log([
'Usage:',
' node scripts/launch-executor.mjs <goose|aider|openhands> [--cwd <dir>] [--mode headless|serve] [--instruction <text>] [--purpose default]',
'',
'Examples:',
' node scripts/launch-executor.mjs aider --cwd /path/to/repo --instruction "fix login button"',
' node scripts/launch-executor.mjs openhands --mode serve --cwd /path/to/repo',
' node scripts/launch-executor.mjs openhands --mode headless --cwd /path/to/repo --instruction "add search"',
].join('\n'));
}
loadEnvFile(path.join(root, '.env'));
loadEnvFile(path.join(root, '.env.local'));
const parsed = parseArgs(process.argv.slice(2));
if (parsed.help || !parsed.executor) {
printHelp();
process.exit(parsed.help ? 0 : 1);
}
const pool = createDbPool();
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 plan = await svc.getExecutorLaunchPlan(parsed.executor, {
cwd: path.resolve(parsed.cwd),
mode: parsed.mode,
instruction: parsed.instruction,
purpose: parsed.purpose,
includeSecret: true,
});
if (!plan.ok) {
console.error(plan.message ?? '执行器启动计划不可用');
await pool.end();
process.exit(1);
}
console.log(`==> ${plan.executorLabel ?? plan.executor} launch plan`);
console.log(`cwd: ${plan.cwd}`);
console.log(`cmd: ${plan.command} ${plan.args?.join(' ') ?? ''}`.trim());
if (plan.notes?.length) {
for (const note of plan.notes) console.log(`note: ${note}`);
}
const child = spawn(plan.command, plan.args ?? [], {
cwd: plan.cwd,
env: { ...process.env, ...(plan.env ?? {}) },
stdio: 'inherit',
});
const shutdown = (code = 0) => {
child.kill('SIGTERM');
setTimeout(() => process.exit(code), 200);
};
process.on('SIGINT', () => shutdown(0));
process.on('SIGTERM', () => shutdown(0));
child.on('exit', async (code, signal) => {
await pool.end().catch(() => {});
if (signal) {
process.exit(0);
return;
}
process.exit(code ?? 0);
});
child.on('error', async (err) => {
console.error(err instanceof Error ? err.message : String(err));
await pool.end().catch(() => {});
process.exit(1);
});
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Studio→105 正向隧道:本机 127.0.0.1:18080 → 105 的 127.0.0.1:8080goose-h5 portal
# Studio→105 正向隧道:本机 127.0.0.1:18080 → 105 的 127.0.0.1:8080memind-h5 portal
# 供 g2-lb Caddy 把一部分 g2 流量加权分到 105,同协议(HTTP) + SSH 加密私网
set -euo pipefail
exec ssh -N -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -o ExitOnForwardFailure=yes -L 127.0.0.1:18080:127.0.0.1:8080 ssh105
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# 反向 SSH 隧道:105 通过 127.0.0.1:19081 访问本机 Portal (:8081)
set -euo pipefail
HOST="${MEMIND_PORTAL_TUNNEL_HOST:-ssh105-public}"
LOCAL_PORT="${MEMIND_PORTAL_TUNNEL_LOCAL_PORT:-8081}"
REMOTE_PORT="${MEMIND_PORTAL_TUNNEL_REMOTE_PORT:-19081}"
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}"
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env node
import { execFileSync, spawnSync } from 'node:child_process';
const DRY_RUN = process.argv.includes('--dry-run');
const FD_WARN = Number(process.env.GOOSED_FD_WARN ?? 180);
const FD_RESTART = Number(process.env.GOOSED_FD_RESTART ?? 3200);
const SANDBOX_MAX_TOTAL = Number(process.env.SANDBOX_MCP_MAX_TOTAL ?? 80);
const SANDBOX_MAX_PER_ROOT = Number(process.env.SANDBOX_MCP_MAX_PER_ROOT ?? 2);
function sh(command) {
return execFileSync('/bin/zsh', ['-lc', command], { encoding: 'utf8' }).trim();
}
function run(command, args) {
if (DRY_RUN) {
console.log(`[dry-run] ${command} ${args.join(' ')}`);
return { status: 0 };
}
return spawnSync(command, args, { stdio: 'inherit' });
}
function psLines(pattern) {
const output = sh(
`ps -axo pid=,ppid=,etime=,command= | grep ${JSON.stringify(pattern)} | grep -v grep || true`,
);
if (!output) return [];
return output.split('\n').map((line) => {
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
if (!match) return null;
return {
pid: Number(match[1]),
ppid: Number(match[2]),
etimes: etimeToSeconds(match[3]),
command: match[4],
};
}).filter(Boolean);
}
function etimeToSeconds(value) {
const parts = String(value ?? '').split('-');
const dayPart = parts.length === 2 ? Number(parts[0]) : 0;
const timePart = parts.at(-1) ?? '0';
const nums = timePart.split(':').map((item) => Number(item));
if (nums.some((item) => !Number.isFinite(item))) return 0;
const [hours, minutes, seconds] =
nums.length === 3 ? nums : nums.length === 2 ? [0, nums[0], nums[1]] : [0, 0, nums[0]];
return dayPart * 86400 + hours * 3600 + minutes * 60 + seconds;
}
function fdCount(pid) {
const count = sh(`lsof -n -p ${pid} 2>/dev/null | wc -l || true`);
return Number(count.trim() || 0);
}
function killPid(pid, reason) {
console.log(`kill pid=${pid} reason=${reason}`);
run('/bin/kill', ['-TERM', String(pid)]);
}
function rootForSandbox(command) {
const marker = 'mindspace-sandbox-mcp.mjs ';
const idx = command.indexOf(marker);
if (idx < 0) return '';
return command.slice(idx + marker.length).trim().split(/\s+/)[0] ?? '';
}
function cleanupSandboxProcesses() {
const processes = psLines('mindspace-sandbox-mcp.mjs');
const byParentRoot = new Map();
for (const proc of processes) {
const root = rootForSandbox(proc.command);
const key = `${proc.ppid}:${root}`;
if (!byParentRoot.has(key)) byParentRoot.set(key, []);
byParentRoot.get(key).push(proc);
}
const toKill = new Map();
for (const group of byParentRoot.values()) {
group.sort((a, b) => b.etimes - a.etimes);
for (const proc of group.slice(0, Math.max(0, group.length - SANDBOX_MAX_PER_ROOT))) {
toKill.set(proc.pid, { proc, reason: 'duplicate-sandbox-root' });
}
}
for (const proc of processes) {
const parentAlive = proc.ppid > 1 && psLines(`^${proc.ppid} `).length > 0;
if (!parentAlive && proc.etimes > 60) {
toKill.set(proc.pid, { proc, reason: 'orphan-sandbox' });
}
}
if (processes.length > SANDBOX_MAX_TOTAL) {
const oldest = [...processes].sort((a, b) => b.etimes - a.etimes);
for (const proc of oldest.slice(0, processes.length - SANDBOX_MAX_TOTAL)) {
toKill.set(proc.pid, { proc, reason: `over-total-${SANDBOX_MAX_TOTAL}` });
}
}
for (const { proc, reason } of toKill.values()) {
killPid(proc.pid, reason);
}
console.log(`sandbox processes=${processes.length} killed=${toKill.size}`);
}
function goosedLabel(command) {
const port = command.match(/\b(18006|18007)\b/)?.[1];
return port ? `cn.tkmind.goosed-${port}` : null;
}
function monitorGoosed() {
const processes = psLines('goosed agent');
for (const proc of processes) {
const fds = fdCount(proc.pid);
console.log(`goosed pid=${proc.pid} fd=${fds} cmd=${proc.command}`);
if (fds >= FD_RESTART) {
const label = goosedLabel(proc.command);
if (label) {
console.log(`restart ${label} fd=${fds} threshold=${FD_RESTART}`);
run('/bin/launchctl', ['kickstart', '-k', `gui/${process.getuid()}/${label}`]);
}
} else if (fds >= FD_WARN) {
console.warn(`warn goosed pid=${proc.pid} fd=${fds} threshold=${FD_WARN}`);
}
}
}
cleanupSandboxProcesses();
monitorGoosed();
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
from pathlib import Path
path = Path("/Users/john/Project/Memind/server.mjs")
text = path.read_text()
if "function verifyWechatMpUrlChallenge" not in text:
text = text.replace(
' acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== "0"\n };\n}',
' acceptLink: env.H5_WECHAT_MP_ACCEPT_LINK !== "0",\n encodingAesKey: env.H5_WECHAT_MP_ENCODING_AES_KEY?.trim() ?? ""\n };\n}',
1,
)
old_sig = """function verifyWechatMpSignature({ token, timestamp, nonce, signature }) {
const digest = crypto27.createHash("sha1").update([token, timestamp, nonce].sort().join("")).digest("hex");
return digest === String(signature ?? "");
}"""
new_sig = """function sha1WechatHex(parts) {
return crypto27.createHash("sha1").update([...parts].sort().join("")).digest("hex");
}
function verifyWechatMpSignature({ token, timestamp, nonce, signature }) {
return sha1WechatHex([token, timestamp, nonce]) === String(signature ?? "");
}
function verifyWechatMpMsgSignature({ token, timestamp, nonce, echoStr, msgSignature }) {
return sha1WechatHex([token, timestamp, nonce, echoStr]) === String(msgSignature ?? "");
}
function decodeWechatMpAesKey(encodingAesKey) {
const key = Buffer.from(`${String(encodingAesKey ?? "").trim()}=`, "base64");
if (key.length !== 32) throw new Error("invalid encoding aes key");
return key;
}
function decryptWechatMpPayload({ encodingAesKey, appId, encrypted }) {
const key = decodeWechatMpAesKey(encodingAesKey);
const iv = key.subarray(0, 16);
const decipher = crypto27.createDecipheriv("aes-256-cbc", key, iv);
decipher.setAutoPadding(false);
const cipherBuf = Buffer.from(String(encrypted ?? ""), "base64");
let decoded = Buffer.concat([decipher.update(cipherBuf), decipher.final()]);
const pad = decoded.at(-1);
if (!Number.isInteger(pad) || pad < 1 || pad > 32) throw new Error("invalid padding");
decoded = decoded.subarray(0, decoded.length - pad);
const content = decoded.subarray(16);
const msgLen = content.readUInt32BE(0);
const msg = content.subarray(4, 4 + msgLen).toString("utf8");
const receivedAppId = content.subarray(4 + msgLen).toString("utf8");
if (receivedAppId !== String(appId ?? "")) throw new Error("appid mismatch");
return msg;
}
function verifyWechatMpUrlChallenge(query = {}, config = {}) {
const timestamp = String(query.timestamp ?? "");
const nonce = String(query.nonce ?? "");
const echostr = String(query.echostr ?? "");
const encryptType = String(query.encrypt_type ?? "").toLowerCase();
if (encryptType === "aes") {
const msgSignature = String(query.msg_signature ?? "");
if (!config?.token || !config?.encodingAesKey || !config?.appId) {
return { ok: false, status: 503, body: "wechat mp aes mode not configured" };
}
if (!verifyWechatMpMsgSignature({ token: config.token, timestamp, nonce, echoStr: echostr, msgSignature })) {
return { ok: false, status: 403, body: "invalid signature" };
}
try {
const plain = decryptWechatMpPayload({ encodingAesKey: config.encodingAesKey, appId: config.appId, encrypted: echostr });
return { ok: true, status: 200, body: plain };
} catch {
return { ok: false, status: 403, body: "invalid echostr" };
}
}
if (!verifyWechatMpSignature({ token: config?.token, timestamp, nonce, signature: String(query.signature ?? "") })) {
return { ok: false, status: 403, body: "invalid signature" };
}
return { ok: true, status: 200, body: echostr };
}"""
if old_sig not in text:
raise SystemExit("signature block not found")
text = text.replace(old_sig, new_sig, 1)
text = text.replace(
""" const verifyRequest = (query = {}) => verifyWechatMpSignature({
token: config.token,
timestamp: String(query.timestamp ?? ""),
nonce: String(query.nonce ?? ""),
signature: String(query.signature ?? "")
});""",
""" const verifyRequest = (query = {}) => verifyWechatMpSignature({
token: config.token,
timestamp: String(query.timestamp ?? ""),
nonce: String(query.nonce ?? ""),
signature: String(query.signature ?? "")
});
const verifyUrlChallenge = (query = {}) => verifyWechatMpUrlChallenge(query, config);""",
1,
)
text = text.replace(
" verifyRequest,\n handleInboundMessage,",
" verifyRequest,\n verifyUrlChallenge,\n handleInboundMessage,",
1,
)
path.write_text(text)
print("bundled server.mjs aes patch applied")
else:
print("bundled aes helpers already present")
release = Path("/Users/john/Project/releases/memind-live-before-20260626-095637-d51df2f/wechat-mp.mjs")
if release.exists():
Path("/Users/john/Project/Memind/wechat-mp.mjs").write_text(release.read_text())
print("wechat-mp.mjs restored from release")
+1 -1
View File
@@ -254,7 +254,7 @@ ${contentPreview}`;
console.log(' 摘要:', pageAfter.summary?.slice(0, 120));
console.log(' 内容已变更:', changed ? '是' : '否');
const publicBase = (process.env.H5_PUBLIC_BASE_URL ?? 'https://g2.tkmind.cn').replace(/\/$/, '');
const publicBase = (process.env.H5_PUBLIC_BASE_URL ?? 'https://58.38.22.103').replace(/\/$/, '');
const [pubRows] = await pool.query(
`SELECT url_slug, status FROM h5_publish_records WHERE id = (SELECT current_publish_id FROM h5_page_records WHERE id = ?)`,
[PAGE_ID],
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${STUDIO_HOST:-58.38.22.103}"
REMOTE_ROOT="${STUDIO_REMOTE_ROOT:-/Users/john/Project}"
APP_DIR="${REMOTE_ROOT}/Memind"
INCOMING_DIR="${REMOTE_ROOT}/incoming/memind-portal-runtime"
BACKUP_DIR="${REMOTE_ROOT}/backups/memind"
ARCHIVE_DIR="${REMOTE_ROOT}/archives"
HEALTH_URL="${STUDIO_HEALTH_URL:-http://127.0.0.1:8081/api/status}"
PORTAL_LABEL="cn.tkmind.memind-portal"
LAUNCHD_GUI="gui/$(id -u)"
RELEASE_TS="$(date +%Y%m%d-%H%M%S)"
SHORT_SHA="$(git -C "${ROOT}" rev-parse --short HEAD 2>/dev/null || echo no-git)"
RELEASE_ID="${RELEASE_TS}-${SHORT_SHA}"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/memind-portal-runtime-release.XXXXXX")"
RUNTIME_ROOT="${ROOT}/.runtime/portal"
BUNDLE_PATH="${TMP_DIR}/memind-portal-runtime-${RELEASE_ID}.tar.gz"
MANIFEST_PATH="${TMP_DIR}/memind-portal-runtime-${RELEASE_ID}.manifest.txt"
SHA_PATH="${TMP_DIR}/memind-portal-runtime-${RELEASE_ID}.sha256"
DRY_RUN=0
SKIP_TESTS=0
SKIP_BUILD=0
AUTO_YES=0
cleanup() {
rm -rf "${TMP_DIR}"
}
trap cleanup EXIT
usage() {
cat <<'EOF'
用法:
bash scripts/release-portal-runtime-prod.sh [--dry-run] [--skip-tests] [--skip-build] [--yes]
说明:
1. 本地构建 Portal 无源码 runtime artifact
2. 上传到 103(固定公网地址)
3. 103 备份当前 Memind 全目录 + 持久目录
4. 停止旧 Portal 服务
5. 用 runtime artifact 替换 live 目录,只继承持久目录
6. 启动 Portal,保持旧端口 8081
7. 健康检查通过后,旧源码目录移入 archive,不再保留可运行 live 源码
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1 ;;
--skip-tests) SKIP_TESTS=1 ;;
--skip-build) SKIP_BUILD=1 ;;
--yes|-y) AUTO_YES=1 ;;
-h|--help)
usage
exit 0
;;
*)
echo "未知参数: $1" >&2
usage >&2
exit 1
;;
esac
shift
done
say() {
printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*"
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "缺少命令: $1" >&2
exit 1
}
}
need_cmd ssh
need_cmd scp
need_cmd tar
need_cmd shasum
say "本地预检查"
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo portal-runtime-ssh-ok" >/dev/null
ssh -o BatchMode=yes "${HOST}" "test -d '${APP_DIR}' && test -f '${APP_DIR}/.env'" >/dev/null
if [[ "${SKIP_TESTS}" -ne 1 ]]; then
say "运行最小验证"
(
cd "${ROOT}"
npm test -- --test-name-pattern='publish|space|billing' >/dev/null
)
fi
if [[ "${SKIP_BUILD}" -ne 1 ]]; then
say "构建 Portal runtime"
(
cd "${ROOT}"
node scripts/build-portal-runtime.mjs
)
fi
[[ -d "${RUNTIME_ROOT}" ]] || {
echo "缺少 runtime 目录: ${RUNTIME_ROOT}" >&2
exit 1
}
verify_runtime_artifact() {
local missing=0
for required in server.mjs mindspace-sandbox-mcp.mjs dist package.json scripts/run-memind-portal-prod.sh; 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 "发布编号: ${RELEASE_ID}"
echo "本地 HEAD: $(git -C "${ROOT}" rev-parse HEAD 2>/dev/null || echo unknown)"
echo "说明: 此次只切换 test-memind Portal 到无源码 runtime,不包含 memindadm / memindplaza"
read -r -p "确认继续发布到 103? [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 "persisted_items=.env, MindSpace, data, users, .tailscale, public/plaza-covers, logs"
} > "${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 到 103"
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 "在 103 停旧服务并切换到无源码 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}' PORTAL_LABEL='${PORTAL_LABEL}' LAUNCHD_GUI='${LAUNCHD_GUI}' /bin/bash" <<'REMOTE_SCRIPT'
set -euo pipefail
RUNTIME_DIR="${APP_DIR}.runtime-${RELEASE_ID}"
OLD_LIVE_DIR="${ARCHIVE_DIR}/Memind-source-before-${RELEASE_ID}"
BUNDLE="${INCOMING_DIR}/memind-portal-runtime-${RELEASE_ID}.tar.gz"
MANIFEST="${INCOMING_DIR}/memind-portal-runtime-${RELEASE_ID}.manifest.txt"
SHA_FILE="${INCOMING_DIR}/memind-portal-runtime-${RELEASE_ID}.sha256"
FULL_BACKUP_TAR="${BACKUP_DIR}/memind-full-${RELEASE_ID}-before.tar.gz"
PERSIST_BACKUP_TAR="${BACKUP_DIR}/memind-persisted-${RELEASE_ID}-before.tar.gz"
PERSISTED_ITEMS=(
".env"
"MindSpace"
"data"
"users"
".tailscale"
"public/plaza-covers"
"logs"
)
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
launchctl bootstrap "${LAUNCHD_GUI}" "${HOME}/Library/LaunchAgents/${PORTAL_LABEL}.plist" >/dev/null 2>&1 || true
launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true
if ! curl -sf "${HEALTH_URL}" >/dev/null 2>&1; then
nohup "${APP_DIR}/scripts/run-memind-portal-prod.sh" >> "${HOME}/Library/Logs/memind-portal.log" 2>&1 &
fi
}
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; }
say "校验产物完整性"
cd "${INCOMING_DIR}"
shasum -a 256 -c "$(basename "${SHA_FILE}")"
say "备份当前 live 全目录"
COPYFILE_DISABLE=1 tar --exclude='Memind/.tailscale/*.sock' \
-czf "${FULL_BACKUP_TAR}" -C "$(dirname "${APP_DIR}")" "$(basename "${APP_DIR}")"
say "单独备份持久目录"
tmp_persist_dir="$(mktemp -d "${TMPDIR:-/tmp}/memind-persisted.XXXXXX")"
for item in "${PERSISTED_ITEMS[@]}"; do
if [[ -e "${APP_DIR}/${item}" ]]; then
mkdir -p "${tmp_persist_dir}/$(dirname "${item}")"
if [[ "${item}" == ".tailscale" && -d "${APP_DIR}/${item}" ]]; then
mkdir -p "${tmp_persist_dir}/${item}"
(
cd "${APP_DIR}/${item}"
COPYFILE_DISABLE=1 tar --exclude='*.sock' -cf - .
) | (
cd "${tmp_persist_dir}/${item}"
tar -xf -
)
else
cp -a "${APP_DIR}/${item}" "${tmp_persist_dir}/$(dirname "${item}")/"
fi
fi
done
COPYFILE_DISABLE=1 tar -czf "${PERSIST_BACKUP_TAR}" -C "${tmp_persist_dir}" .
rm -rf "${tmp_persist_dir}"
say "停止旧 Portal 服务"
launchctl bootout "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true
lsof -tiTCP:8081 -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true
sleep 2
lsof -tiTCP:8081 -sTCP:LISTEN 2>/dev/null | xargs kill -9 2>/dev/null || true
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}")"
if [[ "${item}" == ".tailscale" && -d "${APP_DIR}/${item}" ]]; then
mkdir -p "${RUNTIME_DIR}/${item}"
(
cd "${APP_DIR}/${item}"
COPYFILE_DISABLE=1 tar --exclude='*.sock' -cf - .
) | (
cd "${RUNTIME_DIR}/${item}"
tar -xf -
)
else
cp -a "${APP_DIR}/${item}" "$(dirname "${RUNTIME_DIR}/${item}")/"
fi
fi
done
say "将旧源码 live 目录移入 archive"
rm -rf "${OLD_LIVE_DIR}"
mv "${APP_DIR}" "${OLD_LIVE_DIR}"
mv "${RUNTIME_DIR}" "${APP_DIR}"
say "更新 LaunchAgent 指向 runtime 启动脚本"
cat > "${HOME}/Library/LaunchAgents/${PORTAL_LABEL}.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>${PORTAL_LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${APP_DIR}/scripts/run-memind-portal-prod.sh</string>
</array>
<key>WorkingDirectory</key>
<string>${APP_DIR}</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>${HOME}/Library/Logs/memind-portal.log</string>
<key>StandardErrorPath</key>
<string>${HOME}/Library/Logs/memind-portal.log</string>
</dict>
</plist>
EOF
say "启动新的 Portal runtime"
launchctl bootstrap "${LAUNCHD_GUI}" "${HOME}/Library/LaunchAgents/${PORTAL_LABEL}.plist" >/dev/null 2>&1 || true
launchctl enable "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true
if ! launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1; then
nohup "${APP_DIR}/scripts/run-memind-portal-prod.sh" >> "${HOME}/Library/Logs/memind-portal.log" 2>&1 &
fi
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 "检查 live 目录中不再保留源码树"
allowed_live_mjs=(
"${APP_DIR}/server.mjs"
"${APP_DIR}/mindspace-sandbox-mcp.mjs"
)
extra_files=""
while IFS= read -r file; do
[[ -z "${file}" ]] && continue
allowed=0
for allowed_file in "${allowed_live_mjs[@]}"; do
if [[ "${file}" == "${allowed_file}" ]]; then
allowed=1
break
fi
done
if [[ "${allowed}" -eq 0 ]]; then
extra_files+="${file} "
fi
done < <(find "${APP_DIR}" -maxdepth 1 \( -name '*.mjs' -o -name 'docs' -o -name 'src' -o -name 'ops' \))
if [[ -n "${extra_files// /}" ]]; then
echo "unexpected source-like files remain in live dir: ${extra_files}" >&2
exit 1
fi
say "Portal runtime 发布完成"
printf 'live_dir=%s\n' "${APP_DIR}"
printf 'archived_source=%s\n' "${OLD_LIVE_DIR}"
printf 'full_backup=%s\n' "${FULL_BACKUP_TAR}"
printf 'persist_backup=%s\n' "${PERSIST_BACKUP_TAR}"
REMOTE_SCRIPT
say "读取 103 最终状态"
ssh -o BatchMode=yes "${HOST}" "curl -s '${HEALTH_URL}' && echo && ls -la '${APP_DIR}' | sed -n '1,80p' && echo '---' && ls -la '${APP_DIR}/scripts' | sed -n '1,40p'"
+299
View File
@@ -0,0 +1,299 @@
#!/usr/bin/env bash
set -euo pipefail
cat >&2 <<'EOF'
`release-prod.sh` 已停用。
103 Portal 生产必须走无源码 runtime 发布,禁止在 103 解源码包、npm install 或 npm run build。
请改用:
bash scripts/release-portal-runtime-prod.sh --dry-run
bash scripts/release-portal-runtime-prod.sh
说明见:
- docs/no-source-portal-migration.md
- PRODUCTION_RELEASE_RULES.md
- docs/release-deploy.md
EOF
exit 1
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${STUDIO_HOST:-58.38.22.103}"
REMOTE_ROOT="${STUDIO_REMOTE_ROOT:-/Users/john/Project}"
APP_NAME="${STUDIO_APP_NAME:-Memind}"
APP_DIR="${REMOTE_ROOT}/${APP_NAME}"
INCOMING_DIR="${REMOTE_ROOT}/incoming/memind"
RELEASES_DIR="${REMOTE_ROOT}/releases"
BACKUP_DIR="${REMOTE_ROOT}/backups/memind"
IGNORE_FILE="${ROOT}/scripts/.releaseignore-prod"
HEALTH_URL="${STUDIO_HEALTH_URL:-http://127.0.0.1:8081/api/status}"
PLAZA_HEALTH_URL="${STUDIO_PLAZA_HEALTH_URL:-http://127.0.0.1:3001/plaza}"
LAUNCHD_GUI="gui/$(id -u)"
PORTAL_LABEL="cn.tkmind.memind-portal"
PLAZA_LABEL="cn.tkmind.plaza"
RELEASE_TS="$(date +%Y%m%d-%H%M%S)"
SHORT_SHA="$(git -C "${ROOT}" rev-parse --short HEAD 2>/dev/null || echo no-git)"
RELEASE_ID="${RELEASE_TS}-${SHORT_SHA}"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/memind-release.XXXXXX")"
BUNDLE_PATH="${TMP_DIR}/memind-${RELEASE_ID}.tar.gz"
MANIFEST_PATH="${TMP_DIR}/memind-${RELEASE_ID}.manifest.txt"
SHA_PATH="${TMP_DIR}/memind-${RELEASE_ID}.sha256"
DRY_RUN=0
SKIP_TESTS=0
SKIP_BUILD=0
AUTO_YES=0
cleanup() {
rm -rf "${TMP_DIR}"
}
trap cleanup EXIT
usage() {
cat <<'EOF'
用法:
bash scripts/release-prod.sh [--dry-run] [--skip-tests] [--skip-build] [--yes]
说明:
1. 本地打包当前工作区为发布产物
2. 上传到 103
3. 103 全量备份当前 Memind
4. 解包、安装依赖、构建
5. 原子切换 live 目录并重启验证
禁止:
- 直接 rsync 到 103
- 在 103 生产目录手改源码后继续运行
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1 ;;
--skip-tests) SKIP_TESTS=1 ;;
--skip-build) SKIP_BUILD=1 ;;
--yes|-y) AUTO_YES=1 ;;
-h|--help)
usage
exit 0
;;
*)
echo "未知参数: $1" >&2
usage >&2
exit 1
;;
esac
shift
done
say() {
printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*"
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "缺少命令: $1" >&2
exit 1
}
}
need_cmd ssh
need_cmd scp
need_cmd tar
need_cmd shasum
[[ -f "${IGNORE_FILE}" ]] || {
echo "缺少忽略文件: ${IGNORE_FILE}" >&2
exit 1
}
say "本地预检查"
git -C "${ROOT}" rev-parse --show-toplevel >/dev/null 2>&1 || true
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo release-ssh-ok" >/dev/null
ssh -o BatchMode=yes "${HOST}" "test -d '${APP_DIR}' && test -f '${APP_DIR}/package.json'" >/dev/null
if [[ "${SKIP_TESTS}" -ne 1 ]]; then
say "运行最小验证"
(
cd "${ROOT}"
npm test -- --test-name-pattern='publish|space|billing' >/dev/null
)
fi
if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then
say "发布确认"
echo "目标主机: ${HOST}"
echo "目标目录: ${APP_DIR}"
echo "发布编号: ${RELEASE_ID}"
echo "本地 HEAD: $(git -C "${ROOT}" rev-parse HEAD 2>/dev/null || echo unknown)"
echo "工作区状态:"
git -C "${ROOT}" status --short --branch | sed -n '1,120p'
read -r -p "确认继续发布到 103? [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 "root=${ROOT}"
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 "git_status_begin"
git -C "${ROOT}" status --short --branch || true
echo "git_status_end"
} > "${MANIFEST_PATH}"
say "打包当前工作区"
tar \
--exclude-from="${IGNORE_FILE}" \
-czf "${BUNDLE_PATH}" \
-C "${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 "上传发布产物到 103"
ssh -o BatchMode=yes "${HOST}" "mkdir -p '${INCOMING_DIR}' '${RELEASES_DIR}' '${BACKUP_DIR}'"
scp -q "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}" "${HOST}:${INCOMING_DIR}/"
say "在 103 备份并发布"
ssh -o BatchMode=yes "${HOST}" \
"RELEASE_ID='${RELEASE_ID}' APP_DIR='${APP_DIR}' INCOMING_DIR='${INCOMING_DIR}' RELEASES_DIR='${RELEASES_DIR}' BACKUP_DIR='${BACKUP_DIR}' HEALTH_URL='${HEALTH_URL}' PLAZA_HEALTH_URL='${PLAZA_HEALTH_URL}' LAUNCHD_GUI='${LAUNCHD_GUI}' PORTAL_LABEL='${PORTAL_LABEL}' PLAZA_LABEL='${PLAZA_LABEL}' SKIP_BUILD='${SKIP_BUILD}' /bin/bash" <<'REMOTE_SCRIPT'
set -euo pipefail
RELEASE_DIR="${RELEASES_DIR}/memind-${RELEASE_ID}"
OLD_LIVE_DIR="${RELEASES_DIR}/memind-live-before-${RELEASE_ID}"
BUNDLE="${INCOMING_DIR}/memind-${RELEASE_ID}.tar.gz"
MANIFEST="${INCOMING_DIR}/memind-${RELEASE_ID}.manifest.txt"
SHA_FILE="${INCOMING_DIR}/memind-${RELEASE_ID}.sha256"
BACKUP_TAR="${BACKUP_DIR}/memind-${RELEASE_ID}-before.tar.gz"
PERSISTED_ITEMS=(
".env"
"MindSpace"
"data"
"users"
".tailscale"
"public/plaza-covers"
"logs"
)
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
launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true
launchctl kickstart -k "${LAUNCHD_GUI}/${PLAZA_LABEL}" >/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; }
say "校验产物完整性"
cd "${INCOMING_DIR}"
shasum -a 256 -c "$(basename "${SHA_FILE}")"
say "全量备份当前 live 目录"
COPYFILE_DISABLE=1 tar --exclude="$(basename "${APP_DIR}")/.tailscale/*.sock" \
-czf "${BACKUP_TAR}" -C "$(dirname "${APP_DIR}")" "$(basename "${APP_DIR}")"
say "准备 release 目录"
rm -rf "${RELEASE_DIR}"
mkdir -p "${RELEASE_DIR}"
tar -xzf "${BUNDLE}" -C "${RELEASE_DIR}"
cp "${MANIFEST}" "${RELEASE_DIR}/.release-manifest.txt"
for item in "${PERSISTED_ITEMS[@]}"; do
if [[ -e "${APP_DIR}/${item}" ]]; then
rm -rf "${RELEASE_DIR:?}/${item}"
mkdir -p "$(dirname "${RELEASE_DIR}/${item}")"
if [[ "${item}" == ".tailscale" && -d "${APP_DIR}/${item}" ]]; then
mkdir -p "${RELEASE_DIR}/${item}"
(
cd "${APP_DIR}/${item}"
COPYFILE_DISABLE=1 tar --exclude='*.sock' -cf - .
) | (
cd "${RELEASE_DIR}/${item}"
tar -xf -
)
else
cp -a "${APP_DIR}/${item}" "$(dirname "${RELEASE_DIR}/${item}")/"
fi
fi
done
say "安装依赖"
cd "${RELEASE_DIR}"
export PATH="/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin:${PATH}"
if command -v npm >/dev/null 2>&1; then
NPM_BIN="$(command -v npm)"
elif [[ -x /opt/homebrew/opt/node@24/bin/npm ]]; then
NPM_BIN="/opt/homebrew/opt/node@24/bin/npm"
else
echo "npm not found on remote" >&2
exit 1
fi
if [[ -f package-lock.json ]]; then
"${NPM_BIN}" ci --no-audit --no-fund
else
"${NPM_BIN}" install --no-audit --no-fund
fi
if [[ "${SKIP_BUILD}" != "1" ]]; then
say "构建前端"
"${NPM_BIN}" run build
fi
say "切换 live 目录"
rm -rf "${OLD_LIVE_DIR}"
mv "${APP_DIR}" "${OLD_LIVE_DIR}"
mv "${RELEASE_DIR}" "${APP_DIR}"
say "重启服务"
launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}"
sleep 3
launchctl kickstart -k "${LAUNCHD_GUI}/${PLAZA_LABEL}"
say "健康检查"
for _ in $(seq 1 60); do
portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)"
plaza_code="$(curl -s -o /dev/null -w '%{http_code}' "${PLAZA_HEALTH_URL}" || true)"
if [[ "${portal_code}" == "200" && "${plaza_code}" == "200" ]]; then
break
fi
sleep 2
done
portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)"
plaza_code="$(curl -s -o /dev/null -w '%{http_code}' "${PLAZA_HEALTH_URL}" || true)"
if [[ "${portal_code}" != "200" || "${plaza_code}" != "200" ]]; then
echo "health check failed: portal=${portal_code} plaza=${plaza_code}" >&2
rm -rf "${APP_DIR}"
mv "${OLD_LIVE_DIR}" "${APP_DIR}"
launchctl kickstart -k "${LAUNCHD_GUI}/${PORTAL_LABEL}" >/dev/null 2>&1 || true
launchctl kickstart -k "${LAUNCHD_GUI}/${PLAZA_LABEL}" >/dev/null 2>&1 || true
exit 1
fi
say "发布成功"
rm -rf "${OLD_LIVE_DIR}"
rm -f "${BUNDLE}" "${MANIFEST}" "${SHA_FILE}"
echo "backup_tar=${BACKUP_TAR}"
echo "live_dir=${APP_DIR}"
REMOTE_SCRIPT
say "发布完成,读取线上版本摘要"
ssh -o BatchMode=yes "${HOST}" "sed -n '1,20p' '${APP_DIR}/.release-manifest.txt' && echo '---' && curl -s '${HEALTH_URL}' && echo && curl -s -o /dev/null -w 'plaza=%{http_code}\n' '${PLAZA_HEALTH_URL}'"
+2 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Memind Portal 生产进程(g2.tkmind.cn / Plaza API 共用 :8081
# Memind Portal 生产进程(m.tkmind.cn / Plaza API 共用 :8081
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
@@ -14,6 +14,7 @@ fi
export NODE_ENV=production
export H5_PORT="${H5_PORT:-8081}"
export H5_PUBLIC_BASE_URL="https://m.tkmind.cn"
export TKMIND_API_TARGET="${TKMIND_API_TARGET_OVERRIDE:-https://127.0.0.1:18006}"
export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1_OVERRIDE:-https://127.0.0.1:18007}"
+1
View File
@@ -13,6 +13,7 @@ if [[ -f "${ROOT}/.env" ]]; then
fi
export NODE_ENV=production
export H5_PUBLIC_BASE_URL="https://m.tkmind.cn"
PORTAL_PORT="${H5_PORT:-8081}"
PLAZA_PORT="${PLAZA_PORT:-3001}"
+6 -3
View File
@@ -12,7 +12,10 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const plazaDir = process.env.PLAZA_APP_DIR ?? path.join(root, '../tkmind_go/ui/plaza');
const defaultPlazaDir = fs.existsSync(path.join(root, '../test-memindplaza/app/plaza/package.json'))
? path.join(root, '../test-memindplaza/app/plaza')
: path.join(root, '../tkmind_go/ui/plaza');
const plazaDir = process.env.PLAZA_APP_DIR ?? defaultPlazaDir;
const portalPort = Number(process.env.H5_PORT ?? 8081);
const plazaPort = Number(process.env.PLAZA_PORT ?? 3001);
const plazaHost = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn';
@@ -99,14 +102,14 @@ process.on('SIGINT', () => shutdown(0));
process.on('SIGTERM', () => shutdown(0));
if (!fs.existsSync(path.join(plazaDir, 'package.json'))) {
console.error(`未找到 Plaza Next.js${plazaDir}`);
console.error(`未找到 Plaza Next.js${plazaDir}。请设置 PLAZA_APP_DIR 指向本地 Plaza 源码,例如 ../test-memindplaza/app/plaza`);
process.exit(1);
}
const mindSpacePublicBase = (
process.env.H5_PUBLIC_BASE_URL ??
process.env.VITE_MINDSPACE_BASE ??
'https://g2.tkmind.cn'
'https://m.tkmind.cn'
).replace(/\/$/, '');
const plazaEnv = {
+11 -52
View File
@@ -1,58 +1,17 @@
#!/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@ssh105}"
REMOTE="${H5_REMOTE_DIR:-/root/tkmind_go/ui/h5}"
SERVICE="${H5_SYSTEMD_SERVICE:-goose-h5}"
cat >&2 <<'EOF'
`scripts/sync-to-105.sh` 已禁用。
echo "======================================"
echo "Memind → 105 同步"
echo "本地: ${ROOT}"
echo "远端: ${HOST}:${REMOTE}"
echo "时间: $(date '+%Y-%m-%d %H:%M:%S')"
echo "======================================"
本机不允许直接 rsync 到 105 阿里云,也不允许把 105 当成源码发布目标。
请改用:
echo "==> rsync 代码 (--delete)..."
rsync -az --delete --exclude-from="${EXCLUDE}" "${ROOT}/" "${HOST}:${REMOTE}/"
bash scripts/release-portal-runtime-prod.sh
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-sandbox-mcp.mjs mindspace-page-edit-session.mjs package.json; do
local_sum="$(openssl md5 "${ROOT}/${f}" 2>/dev/null | awk '{print $2}' \
|| md5 -q "${ROOT}/${f}" 2>/dev/null \
|| md5sum "${ROOT}/${f}" 2>/dev/null | awk '{print $1}')"
remote_sum="$(ssh -o BatchMode=yes "${HOST}" "md5sum '${REMOTE}/${f}' 2>/dev/null | 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 已同步"
规则见:
- ENGINEERING_WORKFLOW_RULES.md
- PRODUCTION_RELEASE_RULES.md
- docs/no-source-portal-migration.md
EOF
exit 1
+12 -8
View File
@@ -14,17 +14,17 @@ const MENU = {
button: [
{
type: 'view',
name: 'TKMind智取',
url: 'https://g2.tkmind.cn',
name: 'MeMind',
url: 'https://m.tkmind.cn',
},
{
type: 'view',
name: 'MiSpace',
url: 'https://g2.tkmind.cn/space',
name: 'M空间',
url: 'https://m.tkmind.cn/space',
},
{
type: 'view',
name: 'MiWorld',
name: 'M广场',
url: 'https://plaza.tkmind.cn',
},
],
@@ -51,10 +51,14 @@ async function readJson(response) {
}
async function getAccessToken() {
const appid = process.env.H5_WECHAT_MP_APP_ID?.trim();
const secret = process.env.H5_WECHAT_MP_APP_SECRET?.trim();
const appid =
process.env.H5_WECHAT_MP_APP_ID?.trim() ?? process.env.H5_WECHAT_APP_ID?.trim() ?? '';
const secret =
process.env.H5_WECHAT_MP_APP_SECRET?.trim() ?? process.env.H5_WECHAT_APP_SECRET?.trim() ?? '';
if (!appid || !secret) {
throw new Error('缺少 H5_WECHAT_MP_APP_ID / H5_WECHAT_MP_APP_SECRET');
throw new Error(
'缺少 H5_WECHAT_MP_APP_ID / H5_WECHAT_MP_APP_SECRET(或 H5_WECHAT_APP_ID / H5_WECHAT_APP_SECRET',
);
}
const tokenUrl = process.env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_TOKEN_URL;