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:
@@ -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*
|
||||
Executable
+41
@@ -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}"
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Executable
+58
@@ -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"
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Studio→105 正向隧道:本机 127.0.0.1:18080 → 105 的 127.0.0.1:8080(goose-h5 portal)
|
||||
# Studio→105 正向隧道:本机 127.0.0.1:18080 → 105 的 127.0.0.1:8080(memind-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
|
||||
|
||||
@@ -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}"
|
||||
Executable
+126
@@ -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();
|
||||
@@ -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")
|
||||
@@ -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],
|
||||
|
||||
Executable
+357
@@ -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'"
|
||||
Executable
+299
@@ -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}'"
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user