Skip to content

Commit dc26fd2

Browse files
committed
macOS 使用用户级启动项恢复 PM2
1 parent 711b7b7 commit dc26fd2

3 files changed

Lines changed: 143 additions & 7 deletions

File tree

docs/operations.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ PM2 不是任务执行所必需的组件。Gateway 才负责 Worker、Scheduler
1414
| 长期后台运行 | `supertask install` | 是;缺失时会显式全局安装 |
1515
| 打开已运行 Gateway 的 Dashboard | `supertask ui` | 否;该命令只打开浏览器 |
1616

17-
`supertask install` 会启动名为 `supertask-gateway` 的 PM2 进程,设置 5 秒重启延迟、最多 30 次重启,并把 PM2 kill timeout 设为 drain 宽限期加 5 秒随后执行 `pm2 save` 并尝试配置 `pm2 startup`。如果系统启动项需要管理员命令,必须按 PM2 输出手工完成。
17+
`supertask install` 会启动名为 `supertask-gateway` 的 PM2 进程,设置 5 秒重启延迟、最多 30 次重启,并把 PM2 kill timeout 设为 drain 宽限期加 5 秒随后执行 `pm2 save`。macOS 会直接安装用户级 `~/Library/LaunchAgents/com.supertask.pm2-resurrect.plist`,登录时运行 `pm2 resurrect`,不需要 sudo;其他系统继续使用 `pm2 startup`,如果需要管理员命令则按 PM2 输出手工完成。
1818

1919
插件加载时会检查 `gateway_lock`:已有新鲜心跳就不处理;没有运行实例且机器已安装 PM2 时,会启动或按包版本重启 Gateway;没有 PM2 时只提示用户,不会静默安装全局依赖。
2020

src/daemon/pm2.ts

Lines changed: 107 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { execSync, spawnSync } from "child_process";
2-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
2+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
33
import { homedir } from "os";
4-
import { dirname, join } from "path";
4+
import { dirname, isAbsolute, join } from "path";
55
import { fileURLToPath } from "url";
66
import { Database } from "bun:sqlite";
77
import { loadConfig } from "../gateway/config";
88

99
const __dirname = dirname(fileURLToPath(import.meta.url));
1010
const PROCESS_NAME = "supertask-gateway";
11+
const MAC_LAUNCH_AGENT_LABEL = "com.supertask.pm2-resurrect";
1112

1213
interface Pm2Process {
1314
name: string;
@@ -76,6 +77,97 @@ function pm2Bin(): string {
7677
?? (process.platform === "win32" ? "pm2.cmd" : "pm2");
7778
}
7879

80+
function xmlEscape(value: string): string {
81+
return value
82+
.replaceAll("&", "&")
83+
.replaceAll("<", "&lt;")
84+
.replaceAll(">", "&gt;")
85+
.replaceAll('"', "&quot;")
86+
.replaceAll("'", "&apos;");
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+
79171
export function isPm2Installed(): boolean {
80172
const result = spawnSync(pm2Bin(), ["--version"], {
81173
stdio: "ignore",
@@ -236,10 +328,19 @@ export function install(): void {
236328
writeRunningVersion(version);
237329
savePm2State();
238330

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+
}
243344
}
244345

245346
console.log("[supertask] Gateway installed and running.");

tests/pm2.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { join } from 'path';
55
import {
66
ensureGateway,
77
getPackageVersion,
8+
installMacLaunchAgent,
89
isGatewayRunning,
910
resolveGatewayEntry,
1011
upgrade,
@@ -19,6 +20,8 @@ const originalEnv = {
1920
db: process.env.SUPERTASK_DB_PATH,
2021
readyTimeout: process.env.SUPERTASK_GATEWAY_READY_TIMEOUT_MS,
2122
killTimeout: process.env.SUPERTASK_PM2_KILL_TIMEOUT_MS,
23+
launchAgent: process.env.SUPERTASK_LAUNCH_AGENT_PATH,
24+
launchctl: process.env.SUPERTASK_LAUNCHCTL_BIN,
2225
};
2326

2427
afterEach(() => {
@@ -30,6 +33,8 @@ afterEach(() => {
3033
restoreEnv('SUPERTASK_DB_PATH', originalEnv.db);
3134
restoreEnv('SUPERTASK_GATEWAY_READY_TIMEOUT_MS', originalEnv.readyTimeout);
3235
restoreEnv('SUPERTASK_PM2_KILL_TIMEOUT_MS', originalEnv.killTimeout);
36+
restoreEnv('SUPERTASK_LAUNCH_AGENT_PATH', originalEnv.launchAgent);
37+
restoreEnv('SUPERTASK_LAUNCHCTL_BIN', originalEnv.launchctl);
3338
});
3439

3540
function restoreEnv(name: string, value: string | undefined): void {
@@ -38,6 +43,36 @@ function restoreEnv(name: string, value: string | undefined): void {
3843
}
3944

4045
describe('PM2 Gateway 管理', () => {
46+
test('macOS 用户级 LaunchAgent 使用 pm2 resurrect 且无需 sudo', () => {
47+
const dir = mkdtempSync(join(tmpdir(), 'supertask-launch-agent-'));
48+
dirs.push(dir);
49+
const fakePm2 = join(dir, 'pm2 & tool');
50+
const fakeLaunchctl = join(dir, 'launchctl');
51+
const launchctlLog = join(dir, 'launchctl.jsonl');
52+
const plist = join(dir, 'LaunchAgents', 'supertask.plist');
53+
writeFileSync(fakePm2, '');
54+
writeFileSync(fakeLaunchctl, `#!/usr/bin/env bun
55+
import { appendFileSync } from 'fs';
56+
appendFileSync(${JSON.stringify(launchctlLog)}, JSON.stringify(Bun.argv.slice(2)) + '\\n');
57+
`);
58+
chmodSync(fakeLaunchctl, 0o755);
59+
process.env.SUPERTASK_PM2_BIN = fakePm2;
60+
process.env.SUPERTASK_LAUNCHCTL_BIN = fakeLaunchctl;
61+
process.env.SUPERTASK_LAUNCH_AGENT_PATH = plist;
62+
63+
expect(installMacLaunchAgent()).toBe(plist);
64+
const contents = readFileSync(plist, 'utf8');
65+
expect(contents).toContain('com.supertask.pm2-resurrect');
66+
expect(contents).toContain(`${fakePm2.replace('&', '&amp;')}`);
67+
expect(contents).toContain('<string>resurrect</string>');
68+
const calls = readFileSync(launchctlLog, 'utf8').trim().split('\n')
69+
.map((line) => JSON.parse(line) as string[]);
70+
expect(calls).toEqual([
71+
['bootout', `gui/${process.getuid()}/com.supertask.pm2-resurrect`],
72+
['bootstrap', `gui/${process.getuid()}`, plist],
73+
]);
74+
});
75+
4176
test('源码和构建目录都使用真实包版本与可用 Gateway 入口', () => {
4277
const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')) as { version: string };
4378
expect(getPackageVersion()).toBe(pkg.version);

0 commit comments

Comments
 (0)