chore: add 105 runtime and openhands ops tooling

This commit is contained in:
john
2026-06-29 07:01:26 +08:00
parent 615a1e9bf4
commit f8787bd330
22 changed files with 1263 additions and 50 deletions
+22 -5
View File
@@ -14,6 +14,8 @@ const schemaFile = path.join(root, 'schema.sql');
const esbuildBin = path.join(root, 'node_modules', '.pnpm', 'node_modules', '.bin', 'esbuild');
const skipBuild = process.argv.includes('--skip-build');
const skipNodeModules = process.argv.includes('--skip-node-modules');
const runtimeNodeTarget = process.env.PORTAL_RUNTIME_NODE_TARGET || 'node24';
const runtimeInstallMode = process.env.PORTAL_RUNTIME_INSTALL_MODE || 'bundle-node-modules';
const externalPackages = [
'@node-rs/argon2',
@@ -84,7 +86,7 @@ async function bundleServer() {
'--bundle',
'--platform=node',
'--format=esm',
'--target=node24',
`--target=${runtimeNodeTarget}`,
'--outfile=.runtime/portal/server.mjs',
'--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
];
@@ -104,7 +106,7 @@ async function bundleSandboxMcp() {
'--bundle',
'--platform=node',
'--format=esm',
'--target=node24',
`--target=${runtimeNodeTarget}`,
'--outfile=.runtime/portal/mindspace-sandbox-mcp.mjs',
'--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
];
@@ -175,10 +177,19 @@ async function rewriteNodeModulesSymlinks(runtimeNodeModulesDir, sourceNodeModul
async function writeMetadata() {
const packageJson = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8'));
const runtimeDependencies = Object.fromEntries(
externalPackages
.filter((pkg) => packageJson.dependencies?.[pkg])
.map((pkg) => [pkg, packageJson.dependencies[pkg]]),
);
const runtimePackageJson = {
name: `${packageJson.name}-portal-runtime`,
private: true,
type: 'module',
engines: {
node: '>=22',
},
dependencies: runtimeDependencies,
};
await writeFile(path.join(runtimeRoot, 'package.json'), `${JSON.stringify(runtimePackageJson, null, 2)}\n`);
await writeFile(
@@ -203,14 +214,15 @@ async function writeMetadata() {
'export TKMIND_API_TARGETS="${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}"',
'export TKMIND_API_TARGET="${TKMIND_API_TARGET:-https://127.0.0.1:18006}"',
'export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1:-https://127.0.0.1:18007}"',
'export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-/usr/local/bin/node}"',
'export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-/opt/portal/mindspace-sandbox-mcp.mjs}"',
'',
'NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"',
'if [[ ! -x "${NODE_BIN}" ]]; then',
' NODE_BIN="$(command -v node)"',
'fi',
'',
'export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-${NODE_BIN}}"',
'export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-${ROOT}/mindspace-sandbox-mcp.mjs}"',
'',
'exec "${NODE_BIN}" "${ROOT}/server.mjs"',
'',
].join('\n'),
@@ -241,6 +253,9 @@ async function writeMetadata() {
'Bundled alongside server.mjs (required for sandbox-fs MCP):',
' mindspace-sandbox-mcp.mjs (esbuild bundle; includes schedule-service deps)',
'',
`Runtime install mode: ${runtimeInstallMode}`,
`Bundle target: ${runtimeNodeTarget}`,
'',
'Key runtime differences must stay in .env, not in the artifact:',
' DATABASE_URL / MYSQL_*',
' H5_PUBLIC_BASE_URL',
@@ -261,7 +276,9 @@ async function main() {
await copyRuntimeAssets();
await bundleServer();
await bundleSandboxMcp();
await copyNodeModules();
if (runtimeInstallMode === 'bundle-node-modules' && !skipNodeModules) {
await copyNodeModules();
}
await writeMetadata();
await fs.chmod(path.join(runtimeRoot, 'scripts', 'run-memind-portal-prod.sh'), 0o755);
await fs.chmod(path.join(runtimeRoot, 'scripts', 'wechat-mp-menu.mjs'), 0o755);
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env node
/**
* LaunchAgent / 后台常驻版 dev 栈:portal + admin + ops + vite。
* 单个子进程异常退出时会自动重启;启动阶段失败则整体退出,由 launchd KeepAlive 重试。
*/
import { spawn, execSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const opsDir = path.join(root, 'ops');
const nodeBin = process.execPath;
const portalPort = Number(process.env.H5_PORT ?? 8081);
const vitePort = Number(process.env.VITE_PORT ?? 5173);
const plazaPort = Number(process.env.PLAZA_PORT ?? 3001);
const opsPort = Number(process.env.OPS_PORT ?? 3002);
const adminPort = Number(process.env.ADMIN_PORT ?? 8082);
const portalUrl = `http://127.0.0.1:${portalPort}`;
const adminUrl = `http://127.0.0.1:${adminPort}`;
const plazaPublicBase = (
process.env.PLAZA_PUBLIC_BASE ?? `http://127.0.0.1:${plazaPort}`
).replace(/\/$/, '');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(root, '../../.env.local'));
loadEnvFile(path.join(root, '.env'));
const logFile =
process.env.MEMIND_DEV_LOG ?? path.join(os.homedir(), 'Library/Logs/memind-dev.log');
function log(message) {
const line = `[${new Date().toISOString()}] ${message}\n`;
try {
fs.appendFileSync(logFile, line);
} catch {
process.stdout.write(line);
}
}
function freePort(port) {
try {
execSync(`lsof -ti TCP:${port} -sTCP:LISTEN | xargs kill -9`, { stdio: 'ignore' });
} catch {
// port already free
}
}
let fullyStarted = false;
let stopping = false;
const children = { server: null, admin: null, ops: null, vite: null };
function shutdown(code = 0) {
if (stopping) return;
stopping = true;
for (const child of Object.values(children)) {
child?.kill('SIGTERM');
}
setTimeout(() => process.exit(code), 300);
}
process.on('SIGINT', () => shutdown(0));
process.on('SIGTERM', () => shutdown(0));
function attachExitHandler(label, child, respawn) {
child.on('exit', (code, signal) => {
if (stopping || signal === 'SIGTERM') return;
if (!code || code === 0) return;
if (!fullyStarted) {
log(`[${label}] 启动阶段退出 code=${code}`);
shutdown(code);
return;
}
log(`[${label}] 异常退出 code=${code}3 秒后重启...`);
setTimeout(() => {
if (stopping) return;
respawn();
}, 3000);
});
}
function spawnPortal() {
const child = spawn(nodeBin, [path.join(root, 'server.mjs')], {
cwd: root,
env: process.env,
stdio: 'inherit',
});
children.server = child;
attachExitHandler('portal', child, spawnPortal);
return child;
}
function spawnAdmin() {
const child = spawn(nodeBin, [path.join(root, 'admin-server.mjs')], {
cwd: root,
env: process.env,
stdio: 'inherit',
});
children.admin = child;
attachExitHandler('admin', child, spawnAdmin);
return child;
}
function spawnOps() {
const child = spawn('npm', ['run', 'dev'], {
cwd: opsDir,
env: { ...process.env, ...opsEnv },
stdio: 'inherit',
});
children.ops = child;
attachExitHandler('ops', child, spawnOps);
return child;
}
function spawnVite() {
const child = spawn('npx', ['vite'], {
cwd: root,
env: { ...process.env, ...viteEnv },
stdio: 'inherit',
});
children.vite = child;
attachExitHandler('vite', child, spawnVite);
return child;
}
async function waitFor(url, check, label, retries = 60) {
for (let i = 0; i < retries; i += 1) {
try {
if (await check(url)) return;
} catch {
// still starting
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`${label} 未在 ${url} 启动`);
}
const mindSpacePublicBase = (
process.env.H5_PUBLIC_BASE_URL ?? `http://127.0.0.1:${vitePort}`
).replace(/\/$/, '');
const viteEnv = {
VITE_PLAZA_BASE: plazaPublicBase,
VITE_MINDSPACE_BASE: mindSpacePublicBase,
};
const opsEnv = {
OPS_API_PROXY: portalUrl,
OPS_ADMIN_PROXY: adminUrl,
VITE_PLAZA_BASE: plazaPublicBase,
VITE_MINDSPACE_BASE: mindSpacePublicBase,
};
log(`清理端口 ${portalPort} / ${adminPort} / ${vitePort} / ${opsPort}...`);
freePort(portalPort);
freePort(adminPort);
freePort(vitePort);
freePort(opsPort);
await new Promise((resolve) => setTimeout(resolve, 500));
log('启动 portal (server.mjs)...');
spawnPortal();
try {
await waitFor(portalUrl, async (url) => (await fetch(`${url}/auth/status`)).ok, 'Portal');
log(`Portal 就绪: ${portalUrl}`);
log('启动 memind_adm (admin-server.mjs)...');
spawnAdmin();
await waitFor(adminUrl, async (url) => (await fetch(`${url}/healthz`)).ok, 'Admin');
log(`memind_adm 就绪: ${adminUrl}`);
log(`启动 Ops 后台 @ http://127.0.0.1:${opsPort}/ops/`);
spawnOps();
await waitFor(
`http://127.0.0.1:${opsPort}`,
async (url) => (await fetch(`${url}/ops/`)).ok,
'Ops',
40,
);
log(`Ops 就绪: http://127.0.0.1:${opsPort}/ops/`);
log(`启动 Vite @ http://127.0.0.1:${vitePort}`);
spawnVite();
await waitFor(
`http://127.0.0.1:${vitePort}`,
async (url) => {
const [rootRes, mainRes] = await Promise.all([
fetch(`${url}/`),
fetch(`${url}/src/main.tsx`),
]);
return rootRes.ok && mainRes.ok;
},
'Vite',
);
log(`Vite 就绪: http://127.0.0.1:${vitePort}`);
fullyStarted = true;
log('');
log('本地服务(LaunchAgent 常驻):');
log(` MindSpace UI http://127.0.0.1:${vitePort}/?preview=mindspace`);
log(` Ops 审核后台 http://127.0.0.1:${opsPort}/ops/`);
log(` API / Portal ${portalUrl}`);
log(` memind_adm ${adminUrl}`);
log('');
log('Plaza 未包含在此 Agent 内;需要时请单独运行 pnpm dev:plaza');
} catch (err) {
log(err instanceof Error ? err.message : String(err));
shutdown(1);
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createDbPool } from '../db.mjs';
import { createLlmProviderService } from '../llm-providers.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
function shellQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function parseArgs(argv) {
const out = {
executor: String(argv[0] ?? '').trim(),
purpose: 'default',
shell: false,
};
for (let i = 1; i < argv.length; i += 1) {
const item = argv[i];
if (item === '--purpose') out.purpose = String(argv[++i] ?? out.purpose);
else if (item === '--shell') out.shell = true;
}
return out;
}
loadEnvFile(path.join(root, '.env'));
loadEnvFile(path.join(root, '.env.local'));
const parsed = parseArgs(process.argv.slice(2));
if (!parsed.executor) {
console.error('Usage: node scripts/executor-env.mjs <executor> [--purpose default] [--shell]');
process.exit(1);
}
const pool = createDbPool();
try {
const svc = createLlmProviderService(pool, {
apiTarget: process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret',
});
const runtime = await svc.getExecutorRuntimeConfig(parsed.executor, {
purpose: parsed.purpose,
includeSecret: true,
});
if (!runtime?.ok) {
console.error(runtime?.message ?? 'executor runtime unavailable');
process.exit(2);
}
const env = runtime.env ?? {};
if (parsed.shell) {
for (const [key, value] of Object.entries(env)) {
process.stdout.write(`export ${key}=${shellQuote(value)}\n`);
}
} else {
process.stdout.write(`${JSON.stringify({ executor: parsed.executor, env })}\n`);
}
} finally {
await pool.end().catch(() => {});
}
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
ENV_FILE="${ENV_FILE:-${ROOT}/.env}"
LABEL="${LABEL:-cn.tkmind.memind-dev}"
PLIST="${HOME}/Library/LaunchAgents/${LABEL}.plist"
LOG_DIR="${HOME}/Library/Logs"
LOG_FILE="${LOG_DIR}/memind-dev.log"
DAEMON_SCRIPT="${ROOT}/scripts/dev-core-daemon.mjs"
UID_NUM="$(id -u)"
GUI="gui/${UID_NUM}"
PATH_EXPORT="/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/usr/local/bin:/usr/bin:/bin"
usage() {
cat <<EOF
用法:
bash scripts/install-memind-dev-launchagent.sh # 安装并启动
bash scripts/install-memind-dev-launchagent.sh --uninstall
环境变量:
ROOT 仓库根目录 (默认: ${ROOT})
LABEL LaunchAgent 标识 (默认: ${LABEL})
NODE_BIN node 可执行文件路径
EOF
}
uninstall() {
if launchctl print "${GUI}/${LABEL}" >/dev/null 2>&1; then
launchctl bootout "${GUI}/${LABEL}" 2>/dev/null || true
sleep 1
fi
rm -f "${PLIST}"
echo "已卸载 LaunchAgent: ${LABEL}"
}
if [[ "${1:-}" == "--uninstall" ]]; then
uninstall
exit 0
fi
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
if [[ ! -f "${ENV_FILE}" ]]; then
echo "未找到环境文件: ${ENV_FILE}" >&2
echo "请先 cp .env.example .env 并配置数据库等变量" >&2
exit 1
fi
if [[ ! -f "${DAEMON_SCRIPT}" ]]; then
echo "未找到 daemon 脚本: ${DAEMON_SCRIPT}" >&2
exit 1
fi
set -a
# shellcheck disable=SC1090
source "${ENV_FILE}"
set +a
NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
if [[ ! -x "${NODE_BIN}" ]]; then
NODE_BIN="$(command -v node || true)"
fi
if [[ ! -x "${NODE_BIN}" ]]; then
echo "未找到 node,请安装 Node.js 或设置 NODE_BIN" >&2
exit 1
fi
if [[ ! -d "${ROOT}/node_modules" ]]; then
echo "未找到 node_modules,请先在 ${ROOT} 执行 pnpm install" >&2
exit 1
fi
if [[ ! -d "${ROOT}/ops/node_modules" ]]; then
echo "未找到 ops/node_modules,请先在 ${ROOT}/ops 执行 npm install" >&2
exit 1
fi
mkdir -p "${HOME}/Library/LaunchAgents" "${LOG_DIR}"
cat >"${PLIST}" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>${NODE_BIN}</string>
<string>${DAEMON_SCRIPT}</string>
</array>
<key>WorkingDirectory</key>
<string>${ROOT}</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>${PATH_EXPORT}</string>
<key>HOME</key>
<string>${HOME}</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>15</integer>
<key>StandardOutPath</key>
<string>${LOG_FILE}</string>
<key>StandardErrorPath</key>
<string>${LOG_FILE}</string>
</dict>
</plist>
EOF
plutil -lint "${PLIST}"
if launchctl print "${GUI}/${LABEL}" >/dev/null 2>&1; then
launchctl bootout "${GUI}/${LABEL}" 2>/dev/null || true
sleep 1
fi
launchctl bootstrap "${GUI}" "${PLIST}"
launchctl enable "${GUI}/${LABEL}"
launchctl kickstart -k "${GUI}/${LABEL}"
echo "等待 H5 开发栈就绪..."
portal_port="${H5_PORT:-8081}"
vite_port="${VITE_PORT:-5173}"
ready=0
for _ in $(seq 1 90); do
if curl -sf "http://127.0.0.1:${portal_port}/auth/status" >/dev/null 2>&1 \
&& curl -sf "http://127.0.0.1:${vite_port}/" >/dev/null 2>&1; then
ready=1
break
fi
sleep 1
done
echo ""
echo "=== Memind 本地开发栈 (LaunchAgent) ==="
launchctl list | grep "${LABEL}" || true
if [[ "${ready}" -eq 1 ]]; then
echo "Portal: http://127.0.0.1:${portal_port}"
echo "H5 UI: http://127.0.0.1:${vite_port}/?preview=mindspace"
else
echo "服务尚未就绪,请查看日志: tail -f ${LOG_FILE}"
fi
echo ""
echo "LaunchAgent: ${PLIST}"
echo "Log file: ${LOG_FILE}"
echo ""
echo "常用命令:"
echo " launchctl kickstart -k ${GUI}/${LABEL} # 重启"
echo " tail -f ${LOG_FILE} # 看日志"
echo " bash scripts/install-memind-dev-launchagent.sh --uninstall"
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="${ROOT_DIR}/docker-compose.openhands-colima.yml"
export OPENHANDS_WORKSPACE="${OPENHANDS_WORKSPACE:-${ROOT_DIR}}"
export OPENHANDS_DOCKER_SOCK="${OPENHANDS_DOCKER_SOCK:-/Users/john/.colima/default/docker.sock}"
export OPENHANDS_CONFIG_DIR="${OPENHANDS_CONFIG_DIR:-/Users/john/.openhands}"
export OPENHANDS_PORT="${OPENHANDS_PORT:-3000}"
export OPENHANDS_SANDBOX_UID="${OPENHANDS_SANDBOX_UID:-$(id -u)}"
export OPENHANDS_DB_NAME="${OPENHANDS_DB_NAME:-openhands}"
export OPENHANDS_DB_USER="${OPENHANDS_DB_USER:-openhands}"
export OPENHANDS_DB_PASS="${OPENHANDS_DB_PASS:-openhands_dev_password}"
if ! command -v docker >/dev/null 2>&1; then
echo "docker not found in PATH" >&2
exit 1
fi
if [ ! -S "${OPENHANDS_DOCKER_SOCK}" ]; then
echo "docker socket not found: ${OPENHANDS_DOCKER_SOCK}" >&2
exit 1
fi
mkdir -p "${OPENHANDS_CONFIG_DIR}"
usage() {
cat <<EOF
Usage: scripts/openhands-colima.sh <pull|up|down|restart|logs|status>
Environment:
OPENHANDS_WORKSPACE Workspace mounted into /workspace
OPENHANDS_DOCKER_SOCK Docker socket mounted into the service container
OPENHANDS_CONFIG_DIR Legacy host config directory (kept for CLI wrappers)
OPENHANDS_PORT Host port published for the GUI server
OPENHANDS_DB_NAME Postgres database name for OpenHands
OPENHANDS_DB_USER Postgres user for OpenHands
OPENHANDS_DB_PASS Postgres password for OpenHands
EOF
}
cmd="${1:-status}"
shift || true
compose() {
docker compose -f "${COMPOSE_FILE}" "$@"
}
case "${cmd}" in
build)
compose build openhands
;;
pull)
compose pull postgres
;;
up)
compose up -d postgres openhands
;;
down)
compose down
;;
restart)
compose up -d --force-recreate postgres openhands
;;
logs)
compose logs -f --tail=200 postgres openhands
;;
status)
compose ps
;;
*)
usage
exit 1
;;
esac
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="/Users/john/Project/Memind"
ENV_SCRIPT="${ROOT_DIR}/scripts/executor-env.mjs"
OPENHANDS_BIN_DEFAULT="/Users/john/.local/bin/openhands"
OPENHANDS_BIN="${OPENHANDS_CLI_BIN:-${OPENHANDS_BIN:-${OPENHANDS_BIN_DEFAULT}}}"
NODE_BIN="${NODE_BIN:-$(command -v node || true)}"
if [[ -z "${NODE_BIN}" ]]; then
echo "node not found in PATH" >&2
exit 1
fi
if [[ ! -x "${OPENHANDS_BIN}" ]]; then
echo "OpenHands CLI not executable: ${OPENHANDS_BIN}" >&2
exit 1
fi
eval "$("${NODE_BIN}" "${ENV_SCRIPT}" openhands --shell)"
exec "${OPENHANDS_BIN}" "$@"
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOST="${H5_DEPLOY_HOST:-root@120.26.184.105}"
REMOTE_ROOT="${REMOTE_ROOT:-/root/tkmind_go}"
APP_DIR="${APP_DIR:-${REMOTE_ROOT}/ui/h5}"
INCOMING_DIR="${INCOMING_DIR:-${REMOTE_ROOT}/incoming/memind-portal-runtime-105}"
BACKUP_DIR="${BACKUP_DIR:-${REMOTE_ROOT}/backups}"
ARCHIVE_DIR="${ARCHIVE_DIR:-${REMOTE_ROOT}/archives}"
SERVICE_NAME="${SERVICE_NAME:-goose-h5}"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8080/api/status}"
RUNTIME_ROOT="${ROOT}/.runtime/portal"
RELEASE_ID="${RELEASE_ID:-$(date +%Y%m%d-%H%M%S)}"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/memind-portal-runtime-105.XXXXXX")"
BUNDLE_PATH="${TMP_DIR}/memind-portal-runtime-105-${RELEASE_ID}.tar.gz"
MANIFEST_PATH="${TMP_DIR}/memind-portal-runtime-105-${RELEASE_ID}.manifest.txt"
SHA_PATH="${TMP_DIR}/memind-portal-runtime-105-${RELEASE_ID}.sha256"
DRY_RUN=0
SKIP_BUILD=0
SKIP_TESTS=0
AUTO_YES=0
cleanup() {
rm -rf "${TMP_DIR}"
}
trap cleanup EXIT
usage() {
cat <<'EOF'
Usage:
bash scripts/release-portal-runtime-105.sh [--dry-run] [--skip-build] [--skip-tests] [--yes]
Behavior:
1. Optionally runs targeted local tests
2. Builds the local Portal runtime artifact
3. Packages .runtime/portal into a tarball
4. Uploads the tarball to 105
5. Replaces the 105 live runtime while preserving config and persisted data
Preserved on 105:
.env, MindSpace/, data/, users/, logs/, public/plaza-covers/
EOF
}
say() {
printf '\n[%s] %s\n' "$(date +%H:%M:%S)" "$*"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run)
DRY_RUN=1
;;
--skip-build)
SKIP_BUILD=1
;;
--skip-tests)
SKIP_TESTS=1
;;
--yes)
AUTO_YES=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
shift
done
cd "${ROOT}"
say "检查 105 SSH"
ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "echo portal-runtime-105-ssh-ok" >/dev/null
if [[ "${SKIP_TESTS}" -ne 1 ]]; then
say "运行微信相关本地测试"
node --test wechat-mp.test.mjs wechat-oauth.test.mjs wechat-pay.test.mjs user-auth.test.mjs capabilities.test.mjs llm-providers.test.mjs
fi
if [[ "${SKIP_BUILD}" -ne 1 ]]; then
say "构建 Portal runtime"
PORTAL_RUNTIME_NODE_TARGET=node22 PORTAL_RUNTIME_INSTALL_MODE=remote-npm-install \
node scripts/build-portal-runtime.mjs --skip-node-modules
fi
verify_runtime_artifact() {
local missing=0
for required in server.mjs mindspace-sandbox-mcp.mjs dist package.json scripts/run-memind-portal-prod.sh scripts/load-env.mjs scripts/wechat-mp-menu.mjs; do
if [[ ! -e "${RUNTIME_ROOT}/${required}" ]]; then
echo "runtime 产物缺失: ${RUNTIME_ROOT}/${required}" >&2
missing=1
fi
done
if [[ "${missing}" -ne 0 ]]; then
echo "请先执行: node scripts/build-portal-runtime.mjs" >&2
exit 1
fi
}
verify_runtime_artifact
if [[ "${AUTO_YES}" -ne 1 && "${DRY_RUN}" -ne 1 ]]; then
say "发布确认"
echo "目标主机: ${HOST}"
echo "目标目录: ${APP_DIR}"
echo "systemd 服务: ${SERVICE_NAME}"
echo "发布编号: ${RELEASE_ID}"
echo "本地 HEAD: $(git -C "${ROOT}" rev-parse HEAD 2>/dev/null || echo unknown)"
read -r -p "确认继续发布到 105? [y/N] " confirm </dev/tty
[[ "${confirm}" =~ ^[Yy]$ ]] || exit 0
fi
say "生成发布清单"
{
echo "release_id=${RELEASE_ID}"
echo "created_at=$(date '+%Y-%m-%d %H:%M:%S %z')"
echo "host=$(hostname)"
echo "git_head=$(git -C "${ROOT}" rev-parse HEAD 2>/dev/null || echo unknown)"
echo "git_branch=$(git -C "${ROOT}" branch --show-current 2>/dev/null || echo detached)"
echo "artifact=.runtime/portal"
echo "target=105"
echo "persisted_items=.env, MindSpace, data, users, logs, public/plaza-covers"
} > "${MANIFEST_PATH}"
say "打包 Portal runtime artifact"
tar -czf "${BUNDLE_PATH}" -C "${RUNTIME_ROOT}" .
checksum="$(shasum -a 256 "${BUNDLE_PATH}" | awk '{print $1}')"
printf '%s %s\n' "${checksum}" "$(basename "${BUNDLE_PATH}")" > "${SHA_PATH}"
if [[ "${DRY_RUN}" -eq 1 ]]; then
say "dry-run 完成"
ls -lh "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}"
exit 0
fi
say "上传 Portal runtime 到 105"
ssh -o BatchMode=yes "${HOST}" "mkdir -p '${INCOMING_DIR}' '${BACKUP_DIR}' '${ARCHIVE_DIR}'"
scp -q "${BUNDLE_PATH}" "${MANIFEST_PATH}" "${SHA_PATH}" "${HOST}:${INCOMING_DIR}/"
say "在 105 切换到新的 runtime"
ssh -o BatchMode=yes "${HOST}" \
"RELEASE_ID='${RELEASE_ID}' APP_DIR='${APP_DIR}' INCOMING_DIR='${INCOMING_DIR}' BACKUP_DIR='${BACKUP_DIR}' ARCHIVE_DIR='${ARCHIVE_DIR}' HEALTH_URL='${HEALTH_URL}' SERVICE_NAME='${SERVICE_NAME}' /bin/bash" <<'REMOTE_SCRIPT'
set -euo pipefail
RUNTIME_DIR="${APP_DIR}.runtime-${RELEASE_ID}"
OLD_LIVE_DIR="${ARCHIVE_DIR}/h5-before-${RELEASE_ID}"
BUNDLE="${INCOMING_DIR}/memind-portal-runtime-105-${RELEASE_ID}.tar.gz"
MANIFEST="${INCOMING_DIR}/memind-portal-runtime-105-${RELEASE_ID}.manifest.txt"
SHA_FILE="${INCOMING_DIR}/memind-portal-runtime-105-${RELEASE_ID}.sha256"
FULL_BACKUP_TAR="${BACKUP_DIR}/h5-full-${RELEASE_ID}-before.tar.gz"
PERSIST_BACKUP_TAR="${BACKUP_DIR}/h5-persisted-${RELEASE_ID}-before.tar.gz"
PERSISTED_ITEMS=(
".env"
"MindSpace"
"data"
"users"
"logs"
"public/plaza-covers"
)
say() {
printf '\n[remote %s] %s\n' "$(date +%H:%M:%S)" "$*"
}
rollback() {
if [[ -d "${OLD_LIVE_DIR}" && ! -d "${APP_DIR}" ]]; then
mv "${OLD_LIVE_DIR}" "${APP_DIR}"
fi
systemctl restart "${SERVICE_NAME}" >/dev/null 2>&1 || true
}
trap 'rollback' ERR
[[ -f "${BUNDLE}" ]] || { echo "missing bundle: ${BUNDLE}" >&2; exit 1; }
[[ -f "${MANIFEST}" ]] || { echo "missing manifest: ${MANIFEST}" >&2; exit 1; }
[[ -f "${SHA_FILE}" ]] || { echo "missing sha file: ${SHA_FILE}" >&2; exit 1; }
[[ -f "${APP_DIR}/.env" ]] || { echo "missing required config: ${APP_DIR}/.env" >&2; exit 1; }
say "校验产物完整性"
cd "${INCOMING_DIR}"
shasum -a 256 -c "$(basename "${SHA_FILE}")"
say "备份当前 live 全目录"
COPYFILE_DISABLE=1 tar -czf "${FULL_BACKUP_TAR}" -C "$(dirname "${APP_DIR}")" "$(basename "${APP_DIR}")"
say "单独备份持久目录"
tmp_persist_dir="$(mktemp -d "${TMPDIR:-/tmp}/memind-105-persisted.XXXXXX")"
for item in "${PERSISTED_ITEMS[@]}"; do
if [[ -e "${APP_DIR}/${item}" ]]; then
mkdir -p "${tmp_persist_dir}/$(dirname "${item}")"
cp -a "${APP_DIR}/${item}" "${tmp_persist_dir}/$(dirname "${item}")/"
fi
done
COPYFILE_DISABLE=1 tar -czf "${PERSIST_BACKUP_TAR}" -C "${tmp_persist_dir}" .
rm -rf "${tmp_persist_dir}"
say "停止旧 Portal 服务"
systemctl stop "${SERVICE_NAME}"
lsof -tiTCP:8080 -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true
sleep 2
say "准备新的 runtime live 目录"
rm -rf "${RUNTIME_DIR}"
mkdir -p "${RUNTIME_DIR}"
tar -xzf "${BUNDLE}" -C "${RUNTIME_DIR}"
cp "${MANIFEST}" "${RUNTIME_DIR}/.release-manifest.txt"
for item in "${PERSISTED_ITEMS[@]}"; do
if [[ -e "${APP_DIR}/${item}" ]]; then
rm -rf "${RUNTIME_DIR:?}/${item}"
mkdir -p "$(dirname "${RUNTIME_DIR}/${item}")"
cp -a "${APP_DIR}/${item}" "$(dirname "${RUNTIME_DIR}/${item}")/"
fi
done
say "在 105 安装 Linux 运行依赖"
cd "${RUNTIME_DIR}"
rm -rf node_modules package-lock.json
npm install --omit=dev --no-package-lock
say "归档旧 live 并切换新 runtime"
rm -rf "${OLD_LIVE_DIR}"
mv "${APP_DIR}" "${OLD_LIVE_DIR}"
mv "${RUNTIME_DIR}" "${APP_DIR}"
say "启动新的 Portal runtime"
systemctl restart "${SERVICE_NAME}"
say "健康检查"
for _ in $(seq 1 60); do
portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)"
if [[ "${portal_code}" == "200" ]]; then
break
fi
sleep 2
done
portal_code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" || true)"
if [[ "${portal_code}" != "200" ]]; then
echo "health check failed: portal=${portal_code}" >&2
exit 1
fi
say "runtime 切换完成"
REMOTE_SCRIPT
say "105 runtime 发布完成"
+11 -26
View File
@@ -191,37 +191,22 @@ if [[ -x "${docker_bin}" ]] && "${docker_bin}" ps --format '{{.Names}}' 2>/dev/n
fi
fi
else
api_secret="$(grep -E '^TKMIND_SERVER__SECRET_KEY=' /Users/john/Project/Memind/.env 2>/dev/null | tail -1 | cut -d= -f2- || true)"
for port in 18006 18007; do
line="$(lsof -nP -iTCP:${port} -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print $1, $2, $9}')"
if [[ -z "${line}" ]]; then
echo "goosed dependency check failed: port ${port} has no listener" >&2
status="$(curl -skS -m 5 -H "X-Secret-Key: ${api_secret}" "https://127.0.0.1:${port}/status" 2>/dev/null || true)"
if [[ "${status}" != "ok" ]]; then
line="$(lsof -nP -iTCP:${port} -sTCP:LISTEN 2>/dev/null | awk 'NR==2 {print $1, $2, $9}')"
echo "goosed dependency check failed: port ${port} status check failed (${line:-no listener})" >&2
missing=1
continue
fi
command_name="${line%% *}"
case "${command_name}" in
goosed) ;;
*)
echo "goosed dependency check failed: port ${port} is listened by ${line}, not goosed" >&2
missing=1
;;
esac
done
if ! launchctl list 2>/dev/null | grep -q 'cn.tkmind.goosed-18006'; then
echo "goosed dependency check failed: launchd service cn.tkmind.goosed-18006 is not loaded" >&2
missing=1
fi
if ! launchctl list 2>/dev/null | grep -q 'cn.tkmind.goosed-18007'; then
echo "goosed dependency check failed: launchd service cn.tkmind.goosed-18007 is not loaded" >&2
missing=1
fi
if [[ ! -x /Users/john/Project/tkmind_go/scripts/run-goosed-local.sh ]]; then
echo "goosed dependency check failed: missing /Users/john/Project/tkmind_go/scripts/run-goosed-local.sh" >&2
missing=1
fi
if [[ ! -x /Users/john/Project/tkmind_go/target/release/goosed && ! -x /Users/john/Project/tkmind_go/target/debug/goosed ]]; then
echo "goosed dependency check failed: missing goosed binary under /Users/john/Project/tkmind_go/target" >&2
portal_mcp_node="$(grep -E '^GOOSED_MCP_NODE_PATH=' /Users/john/Project/Memind/.env 2>/dev/null | tail -1 | cut -d= -f2- || true)"
portal_mcp_server="$(grep -E '^GOOSED_MCP_SERVER_PATH=' /Users/john/Project/Memind/.env 2>/dev/null | tail -1 | cut -d= -f2- || true)"
portal_mcp_node="${portal_mcp_node:-/opt/homebrew/opt/node@24/bin/node}"
portal_mcp_server="${portal_mcp_server:-/Users/john/Project/Memind/mindspace-sandbox-mcp.mjs}"
if [[ ! -x "${portal_mcp_node}" || ! -f "${portal_mcp_server}" ]]; then
echo "goosed dependency check failed: Portal MCP paths are not valid: ${portal_mcp_node}, ${portal_mcp_server}" >&2
missing=1
fi
fi
+4
View File
@@ -14,6 +14,7 @@ fi
export NODE_ENV=production
export H5_PORT="${H5_PORT:-8081}"
export H5_HOST="${H5_HOST:-127.0.0.1}"
export H5_PUBLIC_BASE_URL="https://m.tkmind.cn"
export TKMIND_API_TARGETS="${TKMIND_API_TARGETS_OVERRIDE:-${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}}"
export TKMIND_API_TARGET="${TKMIND_API_TARGET_OVERRIDE:-https://127.0.0.1:18006}"
@@ -24,4 +25,7 @@ if [[ ! -x "${NODE_BIN}" ]]; then
NODE_BIN="$(command -v node)"
fi
export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-${NODE_BIN}}"
export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-${ROOT}/mindspace-sandbox-mcp.mjs}"
exec "${NODE_BIN}" server.mjs