|
1 | 1 | import { execSync, spawnSync } from "child_process"; |
2 | | -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; |
| 2 | +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; |
3 | 3 | import { homedir } from "os"; |
4 | | -import { dirname, join } from "path"; |
| 4 | +import { dirname, isAbsolute, join } from "path"; |
5 | 5 | import { fileURLToPath } from "url"; |
6 | 6 | import { Database } from "bun:sqlite"; |
7 | 7 | import { loadConfig } from "../gateway/config"; |
8 | 8 |
|
9 | 9 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
10 | 10 | const PROCESS_NAME = "supertask-gateway"; |
| 11 | +const MAC_LAUNCH_AGENT_LABEL = "com.supertask.pm2-resurrect"; |
11 | 12 |
|
12 | 13 | interface Pm2Process { |
13 | 14 | name: string; |
@@ -76,6 +77,97 @@ function pm2Bin(): string { |
76 | 77 | ?? (process.platform === "win32" ? "pm2.cmd" : "pm2"); |
77 | 78 | } |
78 | 79 |
|
| 80 | +function xmlEscape(value: string): string { |
| 81 | + return value |
| 82 | + .replaceAll("&", "&") |
| 83 | + .replaceAll("<", "<") |
| 84 | + .replaceAll(">", ">") |
| 85 | + .replaceAll('"', """) |
| 86 | + .replaceAll("'", "'"); |
| 87 | +} |
| 88 | + |
| 89 | +function resolvePm2Bin(): string { |
| 90 | + const configured = pm2Bin(); |
| 91 | + if (isAbsolute(configured)) return configured; |
| 92 | + const result = spawnSync("which", [configured], { encoding: "utf8" }); |
| 93 | + const resolved = result.status === 0 ? result.stdout.trim().split("\n")[0] : ""; |
| 94 | + if (!resolved) throw new Error(`[supertask] 无法解析 pm2 可执行文件: ${configured}`); |
| 95 | + return resolved; |
| 96 | +} |
| 97 | + |
| 98 | +function launchAgentPath(): string { |
| 99 | + return process.env.SUPERTASK_LAUNCH_AGENT_PATH |
| 100 | + ?? join(homedir(), "Library/LaunchAgents", `${MAC_LAUNCH_AGENT_LABEL}.plist`); |
| 101 | +} |
| 102 | + |
| 103 | +function launchctlBin(): string { |
| 104 | + return process.env.SUPERTASK_LAUNCHCTL_BIN ?? "launchctl"; |
| 105 | +} |
| 106 | + |
| 107 | +export function installMacLaunchAgent(): string { |
| 108 | + if (typeof process.getuid !== "function") { |
| 109 | + throw new Error("[supertask] 当前运行时无法获取 macOS 用户 ID"); |
| 110 | + } |
| 111 | + |
| 112 | + const path = launchAgentPath(); |
| 113 | + const home = homedir(); |
| 114 | + const pm2Home = process.env.PM2_HOME ?? join(home, ".pm2"); |
| 115 | + const environmentPath = process.env.PATH |
| 116 | + ?? "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"; |
| 117 | + const plist = `<?xml version="1.0" encoding="UTF-8"?> |
| 118 | +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| 119 | +<plist version="1.0"> |
| 120 | + <dict> |
| 121 | + <key>Label</key> |
| 122 | + <string>${MAC_LAUNCH_AGENT_LABEL}</string> |
| 123 | + <key>ProgramArguments</key> |
| 124 | + <array> |
| 125 | + <string>${xmlEscape(resolvePm2Bin())}</string> |
| 126 | + <string>resurrect</string> |
| 127 | + </array> |
| 128 | + <key>RunAtLoad</key> |
| 129 | + <true/> |
| 130 | + <key>KeepAlive</key> |
| 131 | + <dict> |
| 132 | + <key>SuccessfulExit</key> |
| 133 | + <false/> |
| 134 | + </dict> |
| 135 | + <key>ThrottleInterval</key> |
| 136 | + <integer>10</integer> |
| 137 | + <key>EnvironmentVariables</key> |
| 138 | + <dict> |
| 139 | + <key>HOME</key> |
| 140 | + <string>${xmlEscape(home)}</string> |
| 141 | + <key>PATH</key> |
| 142 | + <string>${xmlEscape(environmentPath)}</string> |
| 143 | + <key>PM2_HOME</key> |
| 144 | + <string>${xmlEscape(pm2Home)}</string> |
| 145 | + </dict> |
| 146 | + <key>StandardErrorPath</key> |
| 147 | + <string>${xmlEscape(join(pm2Home, "supertask-launchd-error.log"))}</string> |
| 148 | + <key>StandardOutPath</key> |
| 149 | + <string>${xmlEscape(join(pm2Home, "supertask-launchd-output.log"))}</string> |
| 150 | + </dict> |
| 151 | +</plist> |
| 152 | +`; |
| 153 | + mkdirSync(dirname(path), { recursive: true }); |
| 154 | + writeFileSync(path, plist, { mode: 0o600 }); |
| 155 | + chmodSync(path, 0o600); |
| 156 | + |
| 157 | + const domain = `gui/${process.getuid()}`; |
| 158 | + spawnSync(launchctlBin(), ["bootout", `${domain}/${MAC_LAUNCH_AGENT_LABEL}`], { |
| 159 | + stdio: "ignore", |
| 160 | + }); |
| 161 | + const loaded = spawnSync(launchctlBin(), ["bootstrap", domain, path], { |
| 162 | + encoding: "utf8", |
| 163 | + }); |
| 164 | + if (loaded.status !== 0) { |
| 165 | + const output = `${loaded.stdout ?? ""}${loaded.stderr ?? ""}`.trim(); |
| 166 | + throw new Error(`[supertask] macOS LaunchAgent 加载失败: ${output || `退出码 ${loaded.status}`}`); |
| 167 | + } |
| 168 | + return path; |
| 169 | +} |
| 170 | + |
79 | 171 | export function isPm2Installed(): boolean { |
80 | 172 | const result = spawnSync(pm2Bin(), ["--version"], { |
81 | 173 | stdio: "ignore", |
@@ -236,10 +328,19 @@ export function install(): void { |
236 | 328 | writeRunningVersion(version); |
237 | 329 | savePm2State(); |
238 | 330 |
|
239 | | - const startup = pm2Exec(["startup"]); |
240 | | - if (startup.output) console.log(startup.output); |
241 | | - if (!startup.ok) { |
242 | | - console.warn("[supertask] pm2 startup 未完成;请按 pm2 输出执行需要管理员权限的命令,然后运行 `pm2 save`。"); |
| 331 | + if (process.platform === "darwin") { |
| 332 | + try { |
| 333 | + const path = installMacLaunchAgent(); |
| 334 | + console.log(`[supertask] macOS LaunchAgent installed: ${path}`); |
| 335 | + } catch (error) { |
| 336 | + console.warn(error instanceof Error ? error.message : String(error)); |
| 337 | + } |
| 338 | + } else { |
| 339 | + const startup = pm2Exec(["startup"]); |
| 340 | + if (startup.output) console.log(startup.output); |
| 341 | + if (!startup.ok) { |
| 342 | + console.warn("[supertask] pm2 startup 未完成;请按 pm2 输出执行需要管理员权限的命令,然后运行 `pm2 save`。"); |
| 343 | + } |
243 | 344 | } |
244 | 345 |
|
245 | 346 | console.log("[supertask] Gateway installed and running."); |
|
0 commit comments