|
| 1 | +#!/usr/bin/env node |
| 2 | +import { spawnSync } from "node:child_process"; |
| 3 | +import process from "node:process"; |
| 4 | +import { pathToFileURL } from "node:url"; |
| 5 | + |
| 6 | +const TMUX_DISABLE_VALUES = new Set(["0", "false", "no", "off"]); |
| 7 | +const TMUX_ATTACH_DISABLE_VALUES = new Set(["0", "false", "no", "off"]); |
| 8 | +const TMUX_ATTACH_FORCE_VALUES = new Set(["1", "true", "yes", "on"]); |
| 9 | +const DEFAULT_PROFILE_NAME = "main"; |
| 10 | +const RAW_WATCH_SCRIPT = "scripts/watch-node.mjs"; |
| 11 | +const TMUX_CHILD_ENV_KEYS = [ |
| 12 | + "NODE_OPTIONS", |
| 13 | + "OPENCLAW_CONFIG_PATH", |
| 14 | + "OPENCLAW_GATEWAY_PORT", |
| 15 | + "OPENCLAW_HOME", |
| 16 | + "OPENCLAW_PROFILE", |
| 17 | + "OPENCLAW_SKIP_CHANNELS", |
| 18 | + "OPENCLAW_STATE_DIR", |
| 19 | +]; |
| 20 | + |
| 21 | +const sanitizeSessionPart = (value) => { |
| 22 | + const normalized = String(value ?? "") |
| 23 | + .trim() |
| 24 | + .toLowerCase() |
| 25 | + .replace(/[^a-z0-9_.-]+/g, "-") |
| 26 | + .replace(/^-+|-+$/g, ""); |
| 27 | + return normalized || DEFAULT_PROFILE_NAME; |
| 28 | +}; |
| 29 | + |
| 30 | +const shellQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`; |
| 31 | + |
| 32 | +const readArgValue = (args, flag) => { |
| 33 | + const prefix = `${flag}=`; |
| 34 | + for (let index = 0; index < args.length; index += 1) { |
| 35 | + const arg = args[index]; |
| 36 | + if (arg === flag) { |
| 37 | + const next = args[index + 1]; |
| 38 | + return typeof next === "string" && !next.startsWith("-") ? next : null; |
| 39 | + } |
| 40 | + if (typeof arg === "string" && arg.startsWith(prefix)) { |
| 41 | + return arg.slice(prefix.length); |
| 42 | + } |
| 43 | + } |
| 44 | + return null; |
| 45 | +}; |
| 46 | + |
| 47 | +export const resolveGatewayWatchTmuxSessionName = ({ args = [], env = process.env } = {}) => { |
| 48 | + const profile = |
| 49 | + env.OPENCLAW_PROFILE || |
| 50 | + readArgValue(args, "--profile") || |
| 51 | + (args.includes("--dev") ? "dev" : null); |
| 52 | + const port = env.OPENCLAW_GATEWAY_PORT || readArgValue(args, "--port"); |
| 53 | + const parts = [ |
| 54 | + "openclaw", |
| 55 | + "gateway", |
| 56 | + "watch", |
| 57 | + sanitizeSessionPart(profile ?? DEFAULT_PROFILE_NAME), |
| 58 | + ]; |
| 59 | + if (port && port !== "18789") { |
| 60 | + parts.push(sanitizeSessionPart(port)); |
| 61 | + } |
| 62 | + return parts.join("-"); |
| 63 | +}; |
| 64 | + |
| 65 | +const resolveShell = (env) => env.SHELL || "/bin/sh"; |
| 66 | + |
| 67 | +export const buildGatewayWatchTmuxCommand = ({ |
| 68 | + args = [], |
| 69 | + cwd = process.cwd(), |
| 70 | + env = process.env, |
| 71 | + nodePath = process.execPath, |
| 72 | + sessionName, |
| 73 | +} = {}) => { |
| 74 | + const shell = resolveShell(env); |
| 75 | + const childEnv = [ |
| 76 | + "env", |
| 77 | + `OPENCLAW_GATEWAY_WATCH_TMUX_CHILD=1`, |
| 78 | + `OPENCLAW_GATEWAY_WATCH_SESSION=${sessionName}`, |
| 79 | + ...TMUX_CHILD_ENV_KEYS.flatMap((key) => |
| 80 | + env[key] == null || env[key] === "" ? [] : [`${key}=${env[key]}`], |
| 81 | + ), |
| 82 | + ]; |
| 83 | + const watchCommand = [ |
| 84 | + "cd", |
| 85 | + shellQuote(cwd), |
| 86 | + "&&", |
| 87 | + "exec", |
| 88 | + ...childEnv.map(shellQuote), |
| 89 | + shellQuote(nodePath), |
| 90 | + shellQuote(RAW_WATCH_SCRIPT), |
| 91 | + ...args.map(shellQuote), |
| 92 | + ].join(" "); |
| 93 | + return `exec ${shellQuote(shell)} -lc ${shellQuote(watchCommand)}`; |
| 94 | +}; |
| 95 | + |
| 96 | +const runForegroundWatcher = ({ args, cwd, env, nodePath, spawnSyncImpl, stdio = "inherit" }) => { |
| 97 | + const result = spawnSyncImpl(nodePath, [RAW_WATCH_SCRIPT, ...args], { |
| 98 | + cwd, |
| 99 | + env, |
| 100 | + stdio, |
| 101 | + }); |
| 102 | + return result.status ?? (result.signal ? 1 : 0); |
| 103 | +}; |
| 104 | + |
| 105 | +const runTmux = (spawnSyncImpl, args, options = {}) => |
| 106 | + spawnSyncImpl("tmux", args, { |
| 107 | + encoding: "utf8", |
| 108 | + stdio: ["ignore", "pipe", "pipe"], |
| 109 | + ...options, |
| 110 | + }); |
| 111 | + |
| 112 | +const log = (stderr, message) => { |
| 113 | + stderr.write(`[openclaw] ${message}\n`); |
| 114 | +}; |
| 115 | + |
| 116 | +const getTmuxErrorText = (result) => |
| 117 | + result.error?.message || String(result.stderr || "").trim() || "unknown error"; |
| 118 | + |
| 119 | +const isMissingTmuxTarget = (result) => |
| 120 | + /can't find (?:session|window|pane)|no current target/i.test(getTmuxErrorText(result)); |
| 121 | + |
| 122 | +const shouldAttachTmux = ({ env, stdinIsTTY, stdoutIsTTY }) => { |
| 123 | + const raw = String(env.OPENCLAW_GATEWAY_WATCH_ATTACH ?? "").toLowerCase(); |
| 124 | + if (TMUX_ATTACH_FORCE_VALUES.has(raw)) { |
| 125 | + return true; |
| 126 | + } |
| 127 | + if (TMUX_ATTACH_DISABLE_VALUES.has(raw)) { |
| 128 | + return false; |
| 129 | + } |
| 130 | + return !env.CI && stdinIsTTY === true && stdoutIsTTY === true; |
| 131 | +}; |
| 132 | + |
| 133 | +const attachTmux = ({ env, sessionName, spawnSyncImpl }) => { |
| 134 | + const args = env.TMUX |
| 135 | + ? ["switch-client", "-t", sessionName] |
| 136 | + : ["attach-session", "-t", sessionName]; |
| 137 | + return runTmux(spawnSyncImpl, args, { stdio: "inherit" }); |
| 138 | +}; |
| 139 | + |
| 140 | +export const runGatewayWatchTmuxMain = (params = {}) => { |
| 141 | + const deps = { |
| 142 | + args: params.args ?? process.argv.slice(2), |
| 143 | + cwd: params.cwd ?? process.cwd(), |
| 144 | + env: params.env ? { ...params.env } : { ...process.env }, |
| 145 | + nodePath: params.nodePath ?? process.execPath, |
| 146 | + spawnSync: params.spawnSync ?? spawnSync, |
| 147 | + stderr: params.stderr ?? process.stderr, |
| 148 | + stdinIsTTY: params.stdinIsTTY ?? process.stdin.isTTY, |
| 149 | + stdout: params.stdout ?? process.stdout, |
| 150 | + stdoutIsTTY: params.stdoutIsTTY ?? process.stdout.isTTY, |
| 151 | + }; |
| 152 | + |
| 153 | + if (TMUX_DISABLE_VALUES.has(String(deps.env.OPENCLAW_GATEWAY_WATCH_TMUX ?? "").toLowerCase())) { |
| 154 | + return runForegroundWatcher({ |
| 155 | + args: deps.args, |
| 156 | + cwd: deps.cwd, |
| 157 | + env: deps.env, |
| 158 | + nodePath: deps.nodePath, |
| 159 | + spawnSyncImpl: deps.spawnSync, |
| 160 | + }); |
| 161 | + } |
| 162 | + |
| 163 | + if (deps.env.OPENCLAW_GATEWAY_WATCH_TMUX_CHILD === "1") { |
| 164 | + return runForegroundWatcher({ |
| 165 | + args: deps.args, |
| 166 | + cwd: deps.cwd, |
| 167 | + env: deps.env, |
| 168 | + nodePath: deps.nodePath, |
| 169 | + spawnSyncImpl: deps.spawnSync, |
| 170 | + }); |
| 171 | + } |
| 172 | + |
| 173 | + const sessionName = |
| 174 | + params.sessionName ?? resolveGatewayWatchTmuxSessionName({ args: deps.args, env: deps.env }); |
| 175 | + const command = buildGatewayWatchTmuxCommand({ |
| 176 | + args: deps.args, |
| 177 | + cwd: deps.cwd, |
| 178 | + env: deps.env, |
| 179 | + nodePath: deps.nodePath, |
| 180 | + sessionName, |
| 181 | + }); |
| 182 | + |
| 183 | + const hasSession = runTmux(deps.spawnSync, ["has-session", "-t", sessionName]); |
| 184 | + if (hasSession.error?.code === "ENOENT") { |
| 185 | + log( |
| 186 | + deps.stderr, |
| 187 | + "tmux is not installed or not on PATH; run `pnpm gateway:watch:raw` for foreground watch mode.", |
| 188 | + ); |
| 189 | + return 1; |
| 190 | + } |
| 191 | + if (hasSession.error) { |
| 192 | + log(deps.stderr, `failed to query tmux session ${sessionName}: ${hasSession.error.message}`); |
| 193 | + return 1; |
| 194 | + } |
| 195 | + |
| 196 | + const startSession = () => |
| 197 | + runTmux(deps.spawnSync, ["new-session", "-d", "-s", sessionName, "-c", deps.cwd, command]); |
| 198 | + const restartSession = () => |
| 199 | + runTmux(deps.spawnSync, ["respawn-pane", "-k", "-t", sessionName, "-c", deps.cwd, command]); |
| 200 | + const action = hasSession.status === 0 ? "restarted" : "started"; |
| 201 | + let result = hasSession.status === 0 ? restartSession() : startSession(); |
| 202 | + if (hasSession.status === 0 && isMissingTmuxTarget(result)) { |
| 203 | + runTmux(deps.spawnSync, ["kill-session", "-t", sessionName]); |
| 204 | + result = startSession(); |
| 205 | + } |
| 206 | + if (result.error?.code === "ENOENT") { |
| 207 | + log( |
| 208 | + deps.stderr, |
| 209 | + "tmux is not installed or not on PATH; run `pnpm gateway:watch:raw` for foreground watch mode.", |
| 210 | + ); |
| 211 | + return 1; |
| 212 | + } |
| 213 | + if (result.error || result.status !== 0) { |
| 214 | + const detail = getTmuxErrorText(result); |
| 215 | + log( |
| 216 | + deps.stderr, |
| 217 | + `failed to ${action === "started" ? "start" : "restart"} tmux session ${sessionName}: ${detail}`, |
| 218 | + ); |
| 219 | + return result.status || 1; |
| 220 | + } |
| 221 | + |
| 222 | + log(deps.stderr, `gateway:watch ${action} in tmux session ${sessionName}`); |
| 223 | + if ( |
| 224 | + shouldAttachTmux({ |
| 225 | + env: deps.env, |
| 226 | + stdinIsTTY: deps.stdinIsTTY, |
| 227 | + stdoutIsTTY: deps.stdoutIsTTY, |
| 228 | + }) |
| 229 | + ) { |
| 230 | + const attachResult = attachTmux({ |
| 231 | + env: deps.env, |
| 232 | + sessionName, |
| 233 | + spawnSyncImpl: deps.spawnSync, |
| 234 | + }); |
| 235 | + if (attachResult.error || attachResult.status !== 0) { |
| 236 | + const detail = |
| 237 | + attachResult.error?.message || String(attachResult.stderr || "").trim() || "unknown error"; |
| 238 | + log(deps.stderr, `failed to attach tmux session ${sessionName}: ${detail}`); |
| 239 | + return attachResult.status || 1; |
| 240 | + } |
| 241 | + return 0; |
| 242 | + } |
| 243 | + deps.stdout.write(`Attach: tmux attach -t ${sessionName}\n`); |
| 244 | + deps.stdout.write("Restart: rerun the same pnpm gateway:watch command\n"); |
| 245 | + deps.stdout.write(`Stop: tmux kill-session -t ${sessionName}\n`); |
| 246 | + return 0; |
| 247 | +}; |
| 248 | + |
| 249 | +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { |
| 250 | + process.exit(runGatewayWatchTmuxMain()); |
| 251 | +} |
0 commit comments