diff --git a/apps/memos-local-openclaw/install.sh b/apps/memos-local-openclaw/install.sh index 696ff94e5..b1e927ebf 100644 --- a/apps/memos-local-openclaw/install.sh +++ b/apps/memos-local-openclaw/install.sh @@ -19,11 +19,11 @@ success() { } warn() { - echo -e "${YELLOW}$1${NC}" + echo -e "${YELLOW}$1${NC}" >&2 } error() { - echo -e "${RED}$1${NC}" + echo -e "${RED}$1${NC}" >&2 } node_major_version() { @@ -225,6 +225,25 @@ success "Using global OpenClaw CLI, 使用全局 OpenClaw CLI: ${OPENCLAW_BIN}" PACKAGE_SPEC="${PLUGIN_PACKAGE}@${PLUGIN_VERSION}" EXTENSION_DIR="${OPENCLAW_HOME}/extensions/${PLUGIN_ID}" OPENCLAW_CONFIG_PATH="${OPENCLAW_HOME}/openclaw.json" +TMP_PACK_DIR="" +GATEWAY_RECOVERY_STATE="inactive" + +cleanup_on_exit() { + if [[ "${GATEWAY_RECOVERY_STATE:-inactive}" == "needs_recovery" ]]; then + local recovery_out="" + if ! recovery_out="$("${OPENCLAW_BIN}" gateway start 2>&1)"; then + warn "Gateway recovery start failed; OpenClaw Gateway may still be stopped. Gateway 恢复启动失败,服务可能仍处于停止状态。" + if [[ -n "${recovery_out}" ]]; then + printf '%s\n' "${recovery_out}" | sed 's/^/ /' >&2 + fi + fi + fi + if [[ -n "${TMP_PACK_DIR:-}" ]]; then + rm -rf "${TMP_PACK_DIR}" + fi +} + +trap cleanup_on_exit EXIT update_openclaw_config() { info "Update OpenClaw config, 更新 OpenClaw 配置..." @@ -317,6 +336,7 @@ NODE info "Stop OpenClaw Gateway, 停止 OpenClaw Gateway..." "${OPENCLAW_BIN}" gateway stop >/dev/null 2>&1 || true +GATEWAY_RECOVERY_STATE="needs_recovery" if command -v lsof >/dev/null 2>&1; then PIDS="$(lsof -i :"${PORT}" -t 2>/dev/null || true)" @@ -334,7 +354,6 @@ fi info "Install plugin ${PACKAGE_SPEC}, 安装插件 ${PACKAGE_SPEC}..." TMP_PACK_DIR="$(mktemp -d)" -trap 'rm -rf "${TMP_PACK_DIR}"' EXIT if [[ -f "${PLUGIN_VERSION}" ]]; then info "Using local tarball, 使用本地包: ${PLUGIN_VERSION}" @@ -404,8 +423,15 @@ update_openclaw_config info "Install OpenClaw Gateway service, 安装 OpenClaw Gateway 服务..." "${OPENCLAW_BIN}" gateway install --port "${PORT}" --force 2>&1 || true -success "Start OpenClaw Gateway service, 启动 OpenClaw Gateway 服务..." -"${OPENCLAW_BIN}" gateway start 2>&1 +info "Starting OpenClaw Gateway service, 正在启动 OpenClaw Gateway 服务..." +if ! "${OPENCLAW_BIN}" gateway start 2>&1; then + # Do not repeat the same failed final start from the EXIT cleanup. + GATEWAY_RECOVERY_STATE="final_failed" + error "Failed to start OpenClaw Gateway, OpenClaw Gateway 启动失败" + exit 1 +fi +GATEWAY_RECOVERY_STATE="inactive" +success "OpenClaw Gateway started, OpenClaw Gateway 已启动" info "Starting Memory Viewer, 正在启动记忆面板..." VIEWER_URL="http://127.0.0.1:18799" diff --git a/apps/memos-local-plugin/install.sh b/apps/memos-local-plugin/install.sh index f74e5e089..1d0f56566 100755 --- a/apps/memos-local-plugin/install.sh +++ b/apps/memos-local-plugin/install.sh @@ -264,10 +264,28 @@ BUILT_TARBALL="" STAGE_DIR="" SOURCE_KIND="" # "path" for a local file, "npm" otherwise SOURCE_SPEC="" +GATEWAY_RECOVERY_BIN="" +GATEWAY_RECOVERY_STATE="inactive" + +cleanup_on_exit() { + if [[ "${GATEWAY_RECOVERY_STATE:-inactive}" == "needs_recovery" && -n "${GATEWAY_RECOVERY_BIN:-}" ]]; then + local recovery_out="" + if ! recovery_out="$("${GATEWAY_RECOVERY_BIN}" gateway start 2>&1)"; then + warn "OpenClaw gateway recovery failed; the gateway may still be stopped." + if [[ -n "${recovery_out}" ]]; then + printf '%s\n' "${recovery_out}" | sed 's/^/ /' >&2 + fi + fi + fi + if [[ -n "${STAGE_DIR:-}" ]]; then + rm -rf "${STAGE_DIR}" + fi +} + +trap cleanup_on_exit EXIT resolve_tarball() { STAGE_DIR="$(mktemp -d)" - trap 'rm -rf "${STAGE_DIR}"' EXIT if [[ -n "${VERSION_ARG}" && -f "${VERSION_ARG}" ]]; then BUILT_TARBALL="$(cd "$(dirname "${VERSION_ARG}")" && pwd)/$(basename "${VERSION_ARG}")" @@ -429,11 +447,17 @@ install_openclaw() { mkdir -p "${HOME}/.openclaw" local oc_bin="" + # These remain global because the EXIT trap reads them after this function returns. + # The top-level agent dispatch invokes this installer at most once. + GATEWAY_RECOVERY_BIN="" + GATEWAY_RECOVERY_STATE="inactive" if oc_bin="$(find_openclaw_cli)"; then step "Stopping OpenClaw gateway" "${oc_bin}" gateway stop >/dev/null 2>&1 || true sleep 1 success "Gateway stopped" + GATEWAY_RECOVERY_BIN="${oc_bin}" + GATEWAY_RECOVERY_STATE="needs_recovery" fi deploy_tarball_to_prefix "${prefix}" @@ -608,6 +632,8 @@ NODE || (command -v lsof >/dev/null 2>&1 && lsof -i ":18789" -t >/dev/null 2>&1); then success "OpenClaw gateway already running" else + # Do not repeat the same failed final start from the EXIT cleanup. + GATEWAY_RECOVERY_STATE="final_failed" error "openclaw gateway start failed:" echo "${start_out}" | sed 's/^/ /' >&2 warn "Inspect ~/.openclaw/logs/gateway.err.log for the full reason." @@ -616,6 +642,10 @@ NODE else success "OpenClaw gateway started" fi + # The service started (or was already running), so the viewer fallback must not + # trigger another service start from the EXIT trap. + GATEWAY_RECOVERY_STATE="inactive" + GATEWAY_RECOVERY_BIN="" step "Waiting for Memory Viewer" if wait_for_viewer "${OPENCLAW_PORT}"; then diff --git a/apps/memos-local-plugin/tests/unit/install-gateway-recovery.test.ts b/apps/memos-local-plugin/tests/unit/install-gateway-recovery.test.ts new file mode 100644 index 000000000..1a93d3b76 --- /dev/null +++ b/apps/memos-local-plugin/tests/unit/install-gateway-recovery.test.ts @@ -0,0 +1,211 @@ +import { spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const pluginInstaller = fileURLToPath(new URL("../../install.sh", import.meta.url)); +const legacyInstaller = fileURLToPath( + new URL("../../../memos-local-openclaw/install.sh", import.meta.url), +); + +interface InstallerHarness { + root: string; + home: string; + bin: string; + temp: string; + gatewayLog: string; +} + +function writeExecutable(path: string, body: string): void { + writeFileSync(path, `#!/usr/bin/env bash\nset -u\n${body}\n`, "utf8"); + chmodSync(path, 0o755); +} + +function createHarness(): InstallerHarness { + const root = mkdtempSync(join(tmpdir(), "memos-installer-recovery-")); + const home = join(root, "home"); + const bin = join(root, "bin"); + const temp = join(root, "tmp"); + const gatewayLog = join(root, "gateway.log"); + mkdirSync(join(home, ".openclaw"), { recursive: true }); + mkdirSync(bin); + mkdirSync(temp); + + writeExecutable( + join(bin, "node"), + 'if [[ "${1:-}" == "-v" ]]; then printf "v22.0.0\\n"; fi\nexit 0', + ); + writeExecutable(join(bin, "npx"), "exit 0"); + writeExecutable( + join(bin, "npm"), + `case "\${1:-}" in + pack) exit "\${FAKE_NPM_PACK_EXIT:-0}" ;; + install) mkdir -p node_modules/better-sqlite3; exit 0 ;; + rebuild) exit 0 ;; + *) exit 0 ;; +esac`, + ); + writeExecutable( + join(bin, "openclaw"), + `printf '%s\\n' "$*" >> "\${FAKE_GATEWAY_LOG:?}" +if [[ "$*" == "gateway start" ]]; then + if [[ "\${FAKE_GATEWAY_START_EXIT:-0}" != "0" ]]; then + printf 'fake gateway start failure\\n' >&2 + fi + exit "\${FAKE_GATEWAY_START_EXIT:-0}" +fi +exit 0`, + ); + writeExecutable(join(bin, "sleep"), "exit 0"); + writeExecutable(join(bin, "lsof"), "exit 1"); + writeExecutable(join(bin, "curl"), 'exit "${FAKE_CURL_EXIT:-0}"'); + + return { root, home, bin, temp, gatewayLog }; +} + +function runInstaller( + harness: InstallerHarness, + script: string, + args: string[], + extraEnv: NodeJS.ProcessEnv = {}, +) { + return spawnSync("bash", [script, ...args], { + cwd: harness.root, + encoding: "utf8", + timeout: 30_000, + env: { + ...process.env, + ...extraEnv, + HOME: harness.home, + TMPDIR: harness.temp, + PATH: `${harness.bin}:${process.env.PATH ?? ""}`, + FAKE_GATEWAY_LOG: harness.gatewayLog, + }, + }); +} + +function gatewayCalls(harness: InstallerHarness): string[] { + if (!existsSync(harness.gatewayLog)) return []; + return readFileSync(harness.gatewayLog, "utf8").trim().split("\n").filter(Boolean); +} + +function expectTempCleaned(harness: InstallerHarness): void { + expect(readdirSync(harness.temp)).toEqual([]); +} + +describe.skipIf(process.platform === "win32")("installer gateway recovery", () => { + it("restarts the gateway when the unified installer cannot extract the package", () => { + const harness = createHarness(); + try { + const brokenTarball = join(harness.root, "broken.tgz"); + writeFileSync(brokenTarball, "not a tarball", "utf8"); + + const result = runInstaller(harness, pluginInstaller, [ + "--agent", + "openclaw", + "--version", + brokenTarball, + ]); + + expect(result.status).not.toBe(0); + expect(gatewayCalls(harness)).toEqual(["gateway stop", "gateway start"]); + expectTempCleaned(harness); + } finally { + rmSync(harness.root, { recursive: true, force: true }); + } + }); + + it("warns when legacy-installer gateway recovery also fails", () => { + const harness = createHarness(); + try { + const result = runInstaller( + harness, + legacyInstaller, + ["--version", "missing-test-version", "--openclaw-home", join(harness.home, ".openclaw")], + { FAKE_GATEWAY_START_EXIT: "17", FAKE_NPM_PACK_EXIT: "23" }, + ); + + expect(result.status).not.toBe(0); + expect(gatewayCalls(harness)).toEqual(["gateway stop", "gateway start"]); + expect(result.stderr).toContain("Gateway recovery start failed"); + expect(result.stderr).toContain("fake gateway start failure"); + expectTempCleaned(harness); + } finally { + rmSync(harness.root, { recursive: true, force: true }); + } + }); + + it("does not restart the gateway after the unified installer enters foreground fallback", () => { + const harness = createHarness(); + try { + const packageRoot = join(harness.root, "package"); + const runtimeDir = join(packageRoot, "dist", "adapters", "openclaw"); + const tarball = join(harness.root, "plugin.tgz"); + mkdirSync(runtimeDir, { recursive: true }); + writeFileSync(join(packageRoot, "package.json"), '{"name":"test-plugin"}\n', "utf8"); + writeFileSync(join(runtimeDir, "index.js"), "export {};\n", "utf8"); + const tar = spawnSync("tar", ["-czf", tarball, "-C", harness.root, "package"], { + encoding: "utf8", + }); + expect(tar.status, tar.stderr).toBe(0); + + const result = runInstaller( + harness, + pluginInstaller, + ["--agent", "openclaw", "--version", tarball], + { FAKE_CURL_EXIT: "1" }, + ); + + expect(result.status).not.toBe(0); + expect(gatewayCalls(harness)).toEqual(["gateway stop", "gateway start", "gateway"]); + expectTempCleaned(harness); + } finally { + rmSync(harness.root, { recursive: true, force: true }); + } + }); + + it("does not retry a failed final gateway start from the exit trap", () => { + const harness = createHarness(); + try { + const packageRoot = join(harness.root, "package"); + const tarball = join(harness.root, "plugin.tgz"); + mkdirSync(packageRoot); + writeFileSync(join(packageRoot, "package.json"), '{"name":"test-plugin"}\n', "utf8"); + const tar = spawnSync("tar", ["-czf", tarball, "-C", harness.root, "package"], { + encoding: "utf8", + }); + expect(tar.status, tar.stderr).toBe(0); + + const result = runInstaller( + harness, + legacyInstaller, + ["--version", tarball, "--openclaw-home", join(harness.home, ".openclaw")], + { FAKE_GATEWAY_START_EXIT: "17" }, + ); + + expect(result.status).not.toBe(0); + const calls = gatewayCalls(harness); + expect(calls[0]).toBe("gateway stop"); + expect(calls.filter((call) => call === "gateway start")).toHaveLength(1); + expect(result.stdout).toContain("Starting OpenClaw Gateway service"); + expect(result.stdout).not.toContain("OpenClaw Gateway started"); + expect(result.stdout).not.toContain("Start OpenClaw Gateway service"); + expect(result.stderr).toContain("Failed to start OpenClaw Gateway"); + expectTempCleaned(harness); + } finally { + rmSync(harness.root, { recursive: true, force: true }); + } + }); +});