UNPKG

mira-app-server

Version:

Mira Server - standalone server application using mira-app-core

370 lines (368 loc) 14.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.enableAutoStart = enableAutoStart; exports.disableAutoStart = disableAutoStart; exports.stopAutoStart = stopAutoStart; exports.restartAutoStart = restartAutoStart; exports.isVolatileNodePath = isVolatileNodePath; exports.statusAutoStart = statusAutoStart; /** * 跨平台开机自启管理 * * 设计:注册的服务由系统托管(开机/登录时自动拉起,崩溃自动重启)。 * 调用 enableAutoStart 后,当前进程不再自行启动实例,交由系统机制拉起, * 避免与系统的 RunAtLoad 同时启动两个实例导致端口冲突。 * * - macOS :LaunchAgent(用户级 ~/Library/LaunchAgents),登录后拉起,无需 root * - Linux :systemd user service(~/.config/systemd/user/)+ loginctl enable-linger,真正开机自启 * - Windows:任务计划程序(ONLOGON,用户级普通权限),登录时启动;通过 .cmd 启动器避免引号嵌套 */ const child_process_1 = require("child_process"); const fs_1 = __importDefault(require("fs")); const os_1 = __importDefault(require("os")); const path_1 = __importDefault(require("path")); const LABEL = 'com.mira.app-server'; const SERVICE_NAME = 'mira-app-server'; const TASK_NAME = 'MiraAppServer'; /** 当前 cli.js 绝对路径(本模块编译后位于 dist/cli/,cli.js 位于 dist/) */ function cliJsPath() { return path_1.default.join(__dirname, '..', 'cli.js'); } /** 运行目录 ~/.mira(同时存日志、Windows 启动器) */ function runtimeDir() { const dir = path_1.default.join(os_1.default.homedir(), '.mira'); fs_1.default.mkdirSync(dir, { recursive: true }); return dir; } /** 实际使用的 dataPath(默认 ~/.mira-data,与部署脚本一致) */ function resolveDataPath(dataPath) { return dataPath || process.env.DATA_PATH || path_1.default.join(os_1.default.homedir(), '.mira-data'); } /** 启动参数(全部用绝对路径,避免 launchd/systemd 精简 PATH 找不到 node/脚本) */ function startArgv(opts) { return [ process.execPath, cliJsPath(), 'start', '--http-port', String(opts.httpPort), '--ws-port', String(opts.wsPort), '--data-path', resolveDataPath(opts.dataPath), ]; } function run(cmd) { return (0, child_process_1.execSync)(cmd, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }); } function tryRun(cmd) { try { return run(cmd); } catch { return null; } } /** POSIX shell 引用(仅 macOS / Linux 分支使用) */ function shellQuote(s) { return `'${s.replace(/'/g, `'"'"'`)}'`; } function escapeXml(s) { return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); } // ============================== macOS ============================== function enableMacos(opts) { const plistPath = path_1.default.join(os_1.default.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`); fs_1.default.mkdirSync(path_1.default.dirname(plistPath), { recursive: true }); const argv = startArgv(opts); const runDir = runtimeDir(); const argsXml = argv.map(a => ` <string>${escapeXml(a)}</string>`).join('\n'); const plist = `<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>Label</key> <string>${LABEL}</string> <key>ProgramArguments</key> <array> ${argsXml} </array> <key>RunAtLoad</key> <true/> <key>KeepAlive</key> <dict> <key>Crashed</key> <true/> </dict> <key>WorkingDirectory</key> <string>${escapeXml(os_1.default.homedir())}</string> <key>StandardOutPath</key> <string>${escapeXml(path_1.default.join(runDir, 'mira-server.out.log'))}</string> <key>StandardErrorPath</key> <string>${escapeXml(path_1.default.join(runDir, 'mira-server.err.log'))}</string> </dict> </plist> `; fs_1.default.writeFileSync(plistPath, plist, 'utf-8'); const domainTarget = `gui/${process.getuid()}`; // 先卸载旧的(若已存在),再 bootstrap(RunAtLoad=true 会立即拉起实例) tryRun(`launchctl bootout ${domainTarget}/${LABEL} 2>/dev/null`); try { run(`launchctl bootstrap ${domainTarget} ${shellQuote(plistPath)}`); } catch { // fallback 到旧 API(老版本 macOS) tryRun(`launchctl unload -w ${shellQuote(plistPath)} 2>/dev/null`); run(`launchctl load -w ${shellQuote(plistPath)}`); } return plistPath; } function disableMacos() { tryRun(`launchctl bootout gui/${process.getuid()}/${LABEL} 2>/dev/null`); const plistPath = path_1.default.join(os_1.default.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`); if (fs_1.default.existsSync(plistPath)) fs_1.default.unlinkSync(plistPath); } // ============================== Linux ============================== function enableLinux(opts) { if (!tryRun('command -v systemctl')) { throw new Error('未检测到 systemctl(当前系统可能未使用 systemd,如 WSL 默认/容器),无法注册开机自启。'); } const svcDir = path_1.default.join(os_1.default.homedir(), '.config', 'systemd', 'user'); fs_1.default.mkdirSync(svcDir, { recursive: true }); const svcPath = path_1.default.join(svcDir, `${SERVICE_NAME}.service`); const execStart = startArgv(opts).map(shellQuote).join(' '); const service = `[Unit] Description=Mira App Server After=network.target [Service] Type=simple ExecStart=${execStart} Restart=on-failure RestartSec=5 Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin [Install] WantedBy=default.target `; fs_1.default.writeFileSync(svcPath, service, 'utf-8'); run('systemctl --user daemon-reload'); run(`systemctl --user enable ${SERVICE_NAME}`); run(`systemctl --user start ${SERVICE_NAME}`); // 用户服务默认在注销后被杀;开启 lingering 才能真正开机自启(可能需要 polkit 授权) if (!tryRun(`loginctl enable-linger ${os_1.default.userInfo().username}`)) { console.warn('⚠️ loginctl enable-linger 失败:注销后服务可能停止。可手动执行:sudo loginctl enable-linger $USER'); } return svcPath; } function disableLinux() { tryRun(`systemctl --user disable ${SERVICE_NAME} 2>/dev/null`); tryRun(`systemctl --user stop ${SERVICE_NAME} 2>/dev/null`); const svcPath = path_1.default.join(os_1.default.homedir(), '.config', 'systemd', 'user', `${SERVICE_NAME}.service`); if (fs_1.default.existsSync(svcPath)) fs_1.default.unlinkSync(svcPath); tryRun('systemctl --user daemon-reload'); } // ============================== Windows ============================== function enableWindows(opts) { const argv = startArgv(opts); const runDir = runtimeDir(); // 写一个固定内容的 .cmd 启动器,任务计划只 /TR 指向它(避免 schtasks 引号嵌套地狱) const launcherPath = path_1.default.join(runDir, 'mira-app-server-autostart.cmd'); const cmdLines = ['@echo off', argv.map(a => `"${a}"`).join(' ')]; fs_1.default.writeFileSync(launcherPath, cmdLines.join('\r\n') + '\r\n', 'utf-8'); // /SC ONLOGON 当前用户登录时触发;/RL LIMITED 普通权限无需管理员;/F 强制覆盖 run(`schtasks /Create /TN "${TASK_NAME}" /TR "${launcherPath}" /SC ONLOGON /RL LIMITED /F`); tryRun(`schtasks /Run /TN "${TASK_NAME}"`); return launcherPath; } function disableWindows() { tryRun(`schtasks /Delete /TN "${TASK_NAME}" /F`); } // ============================== 对外 API ============================== /** 注册并立即通过系统机制启动;返回注册文件/任务标识 */ function enableAutoStart(opts) { let target; switch (process.platform) { case 'darwin': target = enableMacos(opts); break; case 'linux': target = enableLinux(opts); break; case 'win32': target = enableWindows(opts); break; default: throw new Error(`不支持的平台:${process.platform}(仅支持 darwin / linux / win32)`); } if (isVolatileNodePath(process.execPath)) { console.warn(`⚠️ 注册的 node 路径为版本相关目录:${process.execPath}`); console.warn(' 升级或切换 node 版本后自启可能失效;可运行 `mira-app-server autostart status` 检查,或重新 `start --autostart` 注册。'); } return target; } /** 取消开机自启并停止系统托管的实例 */ function disableAutoStart() { switch (process.platform) { case 'darwin': return disableMacos(); case 'linux': return disableLinux(); case 'win32': return disableWindows(); default: throw new Error(`不支持的平台:${process.platform}`); } } // ============================== 停止 / 重启(系统托管实例) ============================== // 与 disable 的区别:保留自启注册,下次开机/登录仍会自动拉起。 // 仅作用于 `start --autostart` 注册的系统托管实例;前台 start 的实例请用 Ctrl+C 退出。 function stopMacos() { // bootout 卸载并停止当前会话实例;plist 保留,下次登录由 RunAtLoad 重新拉起 tryRun(`launchctl bootout gui/${process.getuid()}/${LABEL} 2>/dev/null`); } function stopLinux() { tryRun(`systemctl --user stop ${SERVICE_NAME} 2>/dev/null`); } function stopWindows() { tryRun(`schtasks /End /TN "${TASK_NAME}"`); } /** 停止系统托管的实例(保留注册,幂等:对未运行的实例也返回成功) */ function stopAutoStart() { switch (process.platform) { case 'darwin': return stopMacos(); case 'linux': return stopLinux(); case 'win32': return stopWindows(); default: throw new Error(`不支持的平台:${process.platform}`); } } function restartMacos() { const uid = process.getuid(); const plistPath = macosPlistPath(); if (!fs_1.default.existsSync(plistPath)) { throw new Error('未找到 LaunchAgent 配置,请先 `mira-app-server start --autostart` 注册。'); } tryRun(`launchctl bootout gui/${uid}/${LABEL} 2>/dev/null`); try { run(`launchctl bootstrap gui/${uid} ${shellQuote(plistPath)}`); } catch { // fallback 到旧 API tryRun(`launchctl unload -w ${shellQuote(plistPath)} 2>/dev/null`); run(`launchctl load -w ${shellQuote(plistPath)}`); } } function restartLinux() { run(`systemctl --user restart ${SERVICE_NAME}`); } function restartWindows() { tryRun(`schtasks /End /TN "${TASK_NAME}"`); run(`schtasks /Run /TN "${TASK_NAME}"`); } /** 重启系统托管的实例 */ function restartAutoStart() { switch (process.platform) { case 'darwin': return restartMacos(); case 'linux': return restartLinux(); case 'win32': return restartWindows(); default: throw new Error(`不支持的平台:${process.platform}`); } } /** 判断 node 路径是否易变(nvm / homebrew Cellar 等版本相关目录) */ function isVolatileNodePath(p) { return /(\/\.nvm\/|\/nvm\/versions\/|\/Cellar\/node\/|\/opt\/homebrew\/opt\/node@)/.test(p) || /\/node-v?\d+\.\d+/.test(p); } function macosPlistPath() { return path_1.default.join(os_1.default.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`); } function linuxServicePath() { return path_1.default.join(os_1.default.homedir(), '.config', 'systemd', 'user', `${SERVICE_NAME}.service`); } function windowsLauncherPath() { return path_1.default.join(runtimeDir(), 'mira-app-server-autostart.cmd'); } function parseMacosNodePath(plist) { const m = plist.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/); if (!m) return null; const s = m[1].match(/<string>([^<]+)<\/string>/); return s ? s[1] : null; } function parseLinuxNodePath(service) { const m = service.match(/^ExecStart=(.+)$/m); if (!m) return null; return m[1].trim().split(/\s+/)[0].replace(/^['"]|['"]$/g, ''); } function parseWindowsNodePath(cmd) { const line = cmd.split(/\r?\n/).find(l => l.trim() && !/^@echo/i.test(l.trim())); if (!line) return null; const m = line.match(/^"([^"]+)"/); return m ? m[1] : line.trim().split(/\s+/)[0]; } function makeStatus(registered, configFile, nodePath, running) { return { platform: process.platform, registered, configFile, nodePath, nodePathValid: nodePath ? fs_1.default.existsSync(nodePath) : false, nodePathVolatile: nodePath ? isVolatileNodePath(nodePath) : false, running, }; } function statusMacos() { const plistPath = macosPlistPath(); const exists = fs_1.default.existsSync(plistPath); let nodePath = null; if (exists) { try { nodePath = parseMacosNodePath(fs_1.default.readFileSync(plistPath, 'utf-8')); } catch { /* ignore */ } } const running = exists ? tryRun(`launchctl print gui/${process.getuid()}/${LABEL}`) !== null : false; return makeStatus(exists, plistPath, nodePath, running); } function statusLinux() { const svcPath = linuxServicePath(); const exists = fs_1.default.existsSync(svcPath); let nodePath = null; if (exists) { try { nodePath = parseLinuxNodePath(fs_1.default.readFileSync(svcPath, 'utf-8')); } catch { /* ignore */ } } let running = null; if (exists) { const r = tryRun(`systemctl --user is-active ${SERVICE_NAME}`); running = r !== null && r.trim() === 'active'; } return makeStatus(exists, svcPath, nodePath, running); } function statusWindows() { const launcherPath = windowsLauncherPath(); const taskExists = tryRun(`schtasks /Query /TN "${TASK_NAME}"`) !== null; let nodePath = null; if (fs_1.default.existsSync(launcherPath)) { try { nodePath = parseWindowsNodePath(fs_1.default.readFileSync(launcherPath, 'utf-8')); } catch { /* ignore */ } } return makeStatus(taskExists, launcherPath, nodePath, taskExists); } /** 查询当前开机自启注册状态与 node 路径健康度 */ function statusAutoStart() { switch (process.platform) { case 'darwin': return statusMacos(); case 'linux': return statusLinux(); case 'win32': return statusWindows(); default: return makeStatus(false, '', null, null); } } //# sourceMappingURL=autostart.js.map