diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index d1713ee0e4c..34ca4efaa87 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -624,12 +624,50 @@ describe("update-cli", () => { expect(runCommandWithTimeout).toHaveBeenCalledWith( [expect.stringMatching(/node/), entryPath, "gateway", "install", "--force"], - expect.objectContaining({ timeoutMs: 60_000 }), + expect.objectContaining({ cwd: root, timeoutMs: 60_000 }), ); expect(runDaemonInstall).not.toHaveBeenCalled(); expect(runRestartScript).toHaveBeenCalled(); }); + it("updateCommand preserves invocation-relative service env overrides during refresh", async () => { + const root = createCaseDir("openclaw-updated-root"); + const entryPath = path.join(root, "dist", "entry.js"); + pathExists.mockImplementation(async (candidate: string) => candidate === entryPath); + + vi.mocked(runGatewayUpdate).mockResolvedValue({ + status: "ok", + mode: "npm", + root, + steps: [], + durationMs: 100, + }); + serviceLoaded.mockResolvedValue(true); + + await withEnvAsync( + { + OPENCLAW_STATE_DIR: "./state", + OPENCLAW_CONFIG_PATH: "./config/openclaw.json", + }, + async () => { + await updateCommand({}); + }, + ); + + expect(runCommandWithTimeout).toHaveBeenCalledWith( + [expect.stringMatching(/node/), entryPath, "gateway", "install", "--force"], + expect.objectContaining({ + cwd: root, + env: expect.objectContaining({ + OPENCLAW_STATE_DIR: path.resolve("./state"), + OPENCLAW_CONFIG_PATH: path.resolve("./config/openclaw.json"), + }), + timeoutMs: 60_000, + }), + ); + expect(runDaemonInstall).not.toHaveBeenCalled(); + }); + it("updateCommand falls back to restart when env refresh install fails", async () => { await runRestartFallbackScenario({ daemonInstall: "fail" }); }); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 6063eb5f163..d0d39e0215a 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -69,6 +69,13 @@ import { suppressDeprecations } from "./suppress-deprecations.js"; const CLI_NAME = resolveCliName(); const SERVICE_REFRESH_TIMEOUT_MS = 60_000; +const SERVICE_REFRESH_PATH_ENV_KEYS = [ + "OPENCLAW_HOME", + "OPENCLAW_STATE_DIR", + "CLAWDBOT_STATE_DIR", + "OPENCLAW_CONFIG_PATH", + "CLAWDBOT_CONFIG_PATH", +] as const; const UPDATE_QUIPS = [ "Leveled up! New skills unlocked. You're welcome.", @@ -117,6 +124,25 @@ function formatCommandFailure(stdout: string, stderr: string): string { return detail.split("\n").slice(-3).join("\n"); } +function resolveServiceRefreshEnv( + env: NodeJS.ProcessEnv, + invocationCwd: string = process.cwd(), +): NodeJS.ProcessEnv { + const resolvedEnv: NodeJS.ProcessEnv = { ...env }; + for (const key of SERVICE_REFRESH_PATH_ENV_KEYS) { + const rawValue = resolvedEnv[key]?.trim(); + if (!rawValue) { + continue; + } + if (rawValue.startsWith("~") || path.isAbsolute(rawValue) || path.win32.isAbsolute(rawValue)) { + resolvedEnv[key] = rawValue; + continue; + } + resolvedEnv[key] = path.resolve(invocationCwd, rawValue); + } + return resolvedEnv; +} + type UpdateDryRunPreview = { dryRun: true; root: string; @@ -190,6 +216,8 @@ async function refreshGatewayServiceEnv(params: { continue; } const res = await runCommandWithTimeout([resolveNodeRunner(), candidate, ...args], { + cwd: params.result.root, + env: resolveServiceRefreshEnv(process.env), timeoutMs: SERVICE_REFRESH_TIMEOUT_MS, }); if (res.code === 0) {