lark-relay 0.6.6 → 0.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/lark-relay.js CHANGED
@@ -10,7 +10,12 @@ const USAGE = `lark-relay ${VERSION} -- Lark 事件中继站
10
10
 
11
11
  lark-relay take ... 阻塞等一批 -> 输出 -> 退出
12
12
  lark-relay status 采集状态 / 事件数量 / 消费断点
13
- lark-relay-collect 常驻采集
13
+ lark-relay-collect 常驻采集(前台)
14
+
15
+ 常驻部署(macOS/launchd,需 sudo):
16
+ lark-relay install 装或重装常驻服务(plist 按当前环境生成)
17
+ lark-relay restart 只改了代码时重启到新版本
18
+ lark-relay uninstall 停服务并删 plist
14
19
 
15
20
  用法详情: lark-relay take --help`
16
21
 
@@ -18,6 +23,7 @@ async function main() {
18
23
  const [cmd, ...rest] = process.argv.slice(2)
19
24
  if (cmd === 'take') return cmdTake(rest)
20
25
  if (cmd === 'status') return cmdStatus(rest)
26
+ if (cmd === 'install' || cmd === 'uninstall' || cmd === 'restart') return cmdService(cmd, rest)
21
27
  if (cmd && !cmd.startsWith('-')) {
22
28
  process.stderr.write(`未知命令:${cmd}\n${USAGE}\n`)
23
29
  return 2
@@ -27,6 +33,16 @@ async function main() {
27
33
  return 0
28
34
  }
29
35
 
36
+ async function cmdService(cmd, argv) {
37
+ const a = parseArgs(argv, {})
38
+ if (a.help) {
39
+ process.stdout.write(`${help.SERVICE_HELP}\n`)
40
+ return 0
41
+ }
42
+ const service = require('../lib/service')
43
+ return service[cmd]({ log: (line) => process.stdout.write(`${line}\n`) })
44
+ }
45
+
30
46
  async function cmdTake(argv) {
31
47
  const a = parseArgs(argv, { keys: TAKE_KEYS })
32
48
  if (a.help || argv.length === 0) {
package/lib/help.js CHANGED
@@ -68,9 +68,30 @@ const STATUS_HELP = `lark-relay status -- 采集状态、事件数量和消费
68
68
 
69
69
  消费者以持锁进程是否存活区分在跑/未运行,未运行的断点仍保留
70
70
  首次 --since all 等待中也会保留起始断点
71
- profiles 列出已配置项,不表示正在采集,也不反映 collect 的 --exclude
71
+ profiles 列出已配置项,不表示正在采集
72
72
  profile 查询失败时保留本地状态并以 exit 1 返回,错误见 collect.profileError
73
73
  扫描全部保留日期,任意位置的损坏事件都标记 ⚠ 并以 exit 1 返回,其余 app 照常显示
74
74
  collect running 只表示主进程在跑,各 app 的连接状态需查看采集日志`
75
75
 
76
- module.exports = { TAKE_HELP, COLLECT_HELP, STATUS_HELP }
76
+ const SERVICE_HELP = `常驻部署 -- 把采集装成 launchd 服务(仅 macOS,需 sudo)
77
+
78
+ sudo -v && lark-relay install 装或重装(幂等;已在跑则先停再起)
79
+ sudo -v && lark-relay restart 只改了代码时重启到新版本
80
+ sudo -v && lark-relay uninstall 停服务并删 plist
81
+
82
+ plist **按当前机器生成**,不存仓内文件:可执行文件位置、HOME、PATH、日志路径
83
+ 都是本机事实,存成文件就得写死用户名,换机器即失效。改行为去改 lib/service.js
84
+
85
+ install 会做:探 sudo 凭据(失败则不动服务) -> 定位 lark-relay-collect ->
86
+ 建日志目录 -> 生成并 plutil 校验 plist -> 卸旧实例并等 label 释放 ->
87
+ 装到 /Library/LaunchDaemons -> bootstrap(最多 3 次) -> 轮询等 state=running
88
+
89
+ **install 与 uninstall 都有丢消息窗口**:事件 8 秒零回放,停摆期间的消息永久丢失,
90
+ 选空闲时段执行。发布流程里改了代码用 restart(不重写 plist)就够
91
+
92
+ label 由 LR_COLLECT_LABEL 覆盖,默认 local.lark-relay-collect;
93
+ status 与本组命令共用同一个定义,不会出现「服务正常却报没在跑」
94
+
95
+ 退出码:0=成功,1=运行错误(服务状态见输出),2=参数错/缺 sudo/非 macOS`
96
+
97
+ module.exports = { TAKE_HELP, COLLECT_HELP, STATUS_HELP, SERVICE_HELP }
package/lib/service.js ADDED
@@ -0,0 +1,297 @@
1
+ 'use strict'
2
+
3
+ // launchd 常驻部署:install / uninstall / restart。
4
+ //
5
+ // **plist 运行时生成,不存仓内文件**:它的每个值都是「当前机器的事实」——
6
+ // 可执行文件位置(fnm 切版本就变)、HOME、PATH、日志路径。存成文件就得写死
7
+ // /Users/<名>,换机器或换用户名即失效,还要靠安装脚本 sed 替换回来。
8
+ // 生成则一律取自 process,零写死路径(2026-09-11 从 deploy-launchd.sh 收进 CLI)。
9
+ //
10
+ // 仅 macOS/launchd:Linux 侧不做常驻(CONVENTIONS.md 的引擎边界)。
11
+ const { execFileSync, execSync } = require('child_process')
12
+ const fs = require('fs')
13
+ const os = require('os')
14
+ const path = require('path')
15
+
16
+ const LABEL = process.env.LR_COLLECT_LABEL || 'local.lark-relay-collect'
17
+ const PLIST_PATH = `/Library/LaunchDaemons/${LABEL}.plist`
18
+
19
+ function die(message, exitCode = 1) {
20
+ throw Object.assign(new Error(message), { userFacing: true, exitCode })
21
+ }
22
+
23
+ function requireDarwin() {
24
+ if (process.platform !== 'darwin') {
25
+ die(`常驻部署仅支持 macOS/launchd,当前 ${process.platform}`, 2)
26
+ }
27
+ }
28
+
29
+ // 采集入口的真实路径。**不能用 process.argv[1]**:那是 lark-relay 而非
30
+ // lark-relay-collect;也不能拼 __dirname,全局安装下 bin 有 npm 生成的 shim。
31
+ //
32
+ // **which 的结果要归一到 fnm 的稳定别名目录**:终端里 PATH 首位是
33
+ // fnm multishell 的临时目录(~/.local/state/fnm_multishells/<pid>_<ts>/bin),
34
+ // 那个目录随 shell 会话消失,写进 plist 则重启后 launchd 找不到程序。
35
+ // $FNM_DIR/aliases/default 是符号链,跟随 fnm default 切换,长期有效。
36
+ // 不用 fnm 时(brew/系统 node)which 的结果本身就稳定,原样返回
37
+ function stableBin(bin) {
38
+ if (!bin.includes('fnm_multishells')) return bin
39
+ const fnmDir = process.env.FNM_DIR || path.join(os.homedir(), '.local/share/fnm')
40
+ const alias = path.join(fnmDir, 'aliases/default/bin', path.basename(bin))
41
+ if (fs.existsSync(alias)) return alias
42
+ die(
43
+ `which 只找到 fnm 临时路径 ${bin},而 ${alias} 不存在。\n` +
44
+ ' 该临时目录随 shell 退出即失效,写进 plist 会导致重启后服务起不来。\n' +
45
+ ' 先 `fnm default <版本>` 并 `npm i -g lark-relay` 让稳定目录里有它。',
46
+ )
47
+ }
48
+
49
+ function resolveCollectBin() {
50
+ try {
51
+ return stableBin(execFileSync('which', ['lark-relay-collect'], { encoding: 'utf8' }).trim())
52
+ } catch (err) {
53
+ if (err.userFacing) throw err
54
+ die('PATH 里找不到 lark-relay-collect,先 npm i -g lark-relay', 2)
55
+ }
56
+ }
57
+
58
+ // launchd 给的默认 PATH 只有 /usr/bin:/bin:/usr/sbin:/sbin,
59
+ // 子进程 lark-cli 与 jq 都找不到。
60
+ //
61
+ // **用固定白名单而非照搬 process.env.PATH**:后者混着 fnm multishell 临时目录、
62
+ // VS Code server 会话目录、/var/run 下的 cryptex 路径这类进程级条目,
63
+ // 写进 plist 就是定时炸弹;且顺序随人当时的 shell 变,两次 install 出不同结果。
64
+ // 只留「采集入口所在目录 + 包管理器前缀 + 系统目录」,去重后拼。
65
+ // home 由调用方传入而不在此处取,便于测试与「装给另一个用户」
66
+ function buildPath(collectBin, home) {
67
+ const candidates = [
68
+ path.dirname(collectBin),
69
+ '/opt/homebrew/bin', '/usr/local/bin', '/usr/local/sbin',
70
+ path.join(home, '.local/bin'),
71
+ '/usr/bin', '/bin', '/usr/sbin', '/sbin',
72
+ ]
73
+ return [...new Set(candidates)].join(':')
74
+ }
75
+
76
+ function buildPlist({ collectBin, home, user, logDir }) {
77
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
78
+ return `<?xml version="1.0" encoding="UTF-8"?>
79
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
80
+ <!--
81
+ 由 \`lark-relay install\` 生成,不要手改 —— 下次 install 会整体覆盖。
82
+ 改行为去改 lib/service.js;形态取舍见 CONVENTIONS.md「launchd 部署」。
83
+ -->
84
+ <plist version="1.0">
85
+ <dict>
86
+ <key>Label</key>
87
+ <string>${esc(LABEL)}</string>
88
+ <key>ProgramArguments</key>
89
+ <array>
90
+ <string>${esc(collectBin)}</string>
91
+ </array>
92
+ <key>EnvironmentVariables</key>
93
+ <dict>
94
+ <!-- launchd 不展开 ~ 和 $VAR,全写字面值;这里是 HOME 的定义源头。
95
+ 不设 LANG:子进程 lark-cli(Node)与 jq 均不依赖 locale 处理中文 -->
96
+ <key>HOME</key>
97
+ <string>${esc(home)}</string>
98
+ <key>PATH</key>
99
+ <string>${esc(buildPath(collectBin, home))}</string>
100
+ </dict>
101
+ <!-- 无头 Mac mini:root 加载、降权跑,开机即起无需登录。
102
+ LaunchAgent 登出即停,而事件 8 秒零回放,停摆=永久丢消息,故不用 Agent -->
103
+ <key>UserName</key>
104
+ <string>${esc(user)}</string>
105
+ <key>RunAtLoad</key>
106
+ <true/>
107
+ <!-- 非零退出时自动重启 -->
108
+ <key>KeepAlive</key>
109
+ <dict>
110
+ <key>SuccessfulExit</key>
111
+ <false/>
112
+ </dict>
113
+ <key>ThrottleInterval</key>
114
+ <integer>5</integer>
115
+ <!-- collect 收摊需 3s,保留 5s;event _bus 的退出由其自身管理 -->
116
+ <key>ExitTimeOut</key>
117
+ <integer>5</integer>
118
+ <!-- Standard 避免 Background 的 IO/CPU 节流 -->
119
+ <key>ProcessType</key>
120
+ <string>Standard</string>
121
+ <!-- 日志父目录须已存在,collect 逐行加时间戳;需要缩减时截断文件 -->
122
+ <key>StandardOutPath</key>
123
+ <string>${esc(path.join(logDir, 'collect.out.log'))}</string>
124
+ <key>StandardErrorPath</key>
125
+ <string>${esc(path.join(logDir, 'collect.err.log'))}</string>
126
+ </dict>
127
+ </plist>
128
+ `
129
+ }
130
+
131
+ function isLoaded() {
132
+ try {
133
+ execFileSync('launchctl', ['print', `system/${LABEL}`], {
134
+ stdio: ['ignore', 'ignore', 'ignore'],
135
+ })
136
+ return true
137
+ } catch {
138
+ return false
139
+ }
140
+ }
141
+
142
+ function serviceState() {
143
+ try {
144
+ const out = execFileSync('launchctl', ['print', `system/${LABEL}`], {
145
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
146
+ })
147
+ return {
148
+ state: ((out.match(/^\s*state\s*=\s*(.+)$/m) || [])[1] || '').trim(),
149
+ pid: Number((out.match(/^\s*pid\s*=\s*(\d+)/m) || [])[1]) || null,
150
+ program: ((out.match(/^\s*program\s*=\s*(.+)$/m) || [])[1] || '').trim(),
151
+ }
152
+ } catch {
153
+ return { state: '', pid: null, program: '' }
154
+ }
155
+ }
156
+
157
+ // sudo 凭据先探再改:失败时服务未被动过,采集照常在跑
158
+ function requireSudo(action) {
159
+ try {
160
+ execFileSync('sudo', ['-n', 'true'], { stdio: 'ignore' })
161
+ } catch {
162
+ die(
163
+ `${action} 需要 sudo。请在终端(不是 AI 会话)先认证再跑,凭据会被复用:\n` +
164
+ ` sudo -v && lark-relay ${action}\n` +
165
+ ' 当前服务未被改动。',
166
+ 2,
167
+ )
168
+ }
169
+ }
170
+
171
+ const sudo = (args) => execFileSync('sudo', args, { stdio: ['ignore', 'ignore', 'pipe'] })
172
+
173
+ // bootout 是异步的:label 不释放就 bootstrap 会失败。等它真消失
174
+ function waitUnloaded(seconds = 30) {
175
+ for (let i = 0; i < seconds; i++) {
176
+ if (!isLoaded()) return true
177
+ execSync('sleep 1')
178
+ }
179
+ return false
180
+ }
181
+
182
+ // bootstrap 后 launchd 先给 spawn scheduled(已排程、进程未起),
183
+ // 固定等待撞上它会把正常部署报成失败(2026-09-10 实测)。轮询等 state 收敛
184
+ function waitRunning(seconds = 15) {
185
+ for (let i = 0; i < seconds; i++) {
186
+ const s = serviceState()
187
+ if (s.state === 'running') return s
188
+ execSync('sleep 1')
189
+ }
190
+ return serviceState()
191
+ }
192
+
193
+ function install({ log = () => {} } = {}) {
194
+ requireDarwin()
195
+ const collectBin = resolveCollectBin()
196
+ const home = os.homedir()
197
+ const user = os.userInfo().username
198
+ const logDir = path.join(process.env.LARK_RELAY_HOME || path.join(home, '.lark-relay'), 'logs')
199
+ const version = execFileSync(collectBin, ['--version'], { encoding: 'utf8' }).trim()
200
+ log(`采集入口:${collectBin}(${version})`)
201
+
202
+ requireSudo('install')
203
+ // launchd 不建目录,日志父目录必须先在
204
+ fs.mkdirSync(logDir, { recursive: true })
205
+
206
+ const body = buildPlist({ collectBin, home, user, logDir })
207
+ const tmp = path.join(os.tmpdir(), `${LABEL}.${process.pid}.plist`)
208
+ fs.writeFileSync(tmp, body, { mode: 0o644 })
209
+ // 装之前先验语法,避免把坏文件 bootstrap 进系统
210
+ try {
211
+ execFileSync('plutil', ['-lint', tmp], { stdio: 'ignore' })
212
+ } catch {
213
+ fs.unlinkSync(tmp)
214
+ die('生成的 plist 语法不合法(请报 bug)')
215
+ }
216
+
217
+ const wasLoaded = isLoaded()
218
+ if (wasLoaded) {
219
+ log('丢消息窗口从现在开始 --')
220
+ try { sudo(['launchctl', 'bootout', `system/${LABEL}`]) } catch {}
221
+ if (!waitUnloaded()) {
222
+ fs.unlinkSync(tmp)
223
+ die(`旧实例 30s 未卸载完,手动 sudo launchctl bootout system/${LABEL} 后重试`)
224
+ }
225
+ }
226
+
227
+ sudo(['install', '-o', 'root', '-g', 'wheel', '-m', '644', tmp, PLIST_PATH])
228
+ fs.unlinkSync(tmp)
229
+ log(`plist 已装:${PLIST_PATH}`)
230
+
231
+ let bootstrapped = false
232
+ for (let attempt = 1; attempt <= 3; attempt++) {
233
+ try {
234
+ sudo(['launchctl', 'bootstrap', 'system', PLIST_PATH])
235
+ bootstrapped = true
236
+ break
237
+ } catch (err) {
238
+ const msg = (err.stderr || '').toString().trim()
239
+ log(`第 ${attempt} 次 bootstrap 失败:${msg}`)
240
+ if (attempt < 3) execSync('sleep 5')
241
+ }
242
+ }
243
+ if (!bootstrapped) {
244
+ die(
245
+ 'bootstrap 三次都失败。服务当前**未运行,正在丢消息**。\n' +
246
+ ` 排查:launchctl print system/${LABEL}\n` +
247
+ ' 应急顶住采集(前台跑,不经 launchd):lark-relay-collect',
248
+ )
249
+ }
250
+
251
+ const s = waitRunning()
252
+ if (s.state !== 'running' || s.program !== collectBin) {
253
+ let tail = ''
254
+ try {
255
+ tail = fs.readFileSync(path.join(logDir, 'collect.err.log'), 'utf8').split('\n').slice(-5).join('\n')
256
+ } catch {}
257
+ die(`部署验证失败:state=${s.state || '未加载'}, program=${s.program}\n${tail}`)
258
+ }
259
+ log(`✓ ${wasLoaded ? '已重装' : '已安装'}并运行中(pid ${s.pid})`)
260
+ log('重连可能持续数分钟(退避重试),期间 status 的 collect 已是 running 但各 app 未必都接上')
261
+ return 0
262
+ }
263
+
264
+ function uninstall({ log = () => {} } = {}) {
265
+ requireDarwin()
266
+ if (!isLoaded() && !fs.existsSync(PLIST_PATH)) {
267
+ log('未安装,无需卸载')
268
+ return 0
269
+ }
270
+ requireSudo('uninstall')
271
+ // 先 bootout 再删文件:只删文件的话跑着的服务会活到下次重启
272
+ if (isLoaded()) {
273
+ try { sudo(['launchctl', 'bootout', `system/${LABEL}`]) } catch {}
274
+ waitUnloaded()
275
+ }
276
+ if (fs.existsSync(PLIST_PATH)) {
277
+ sudo(['rm', '-f', PLIST_PATH])
278
+ log(`已删:${PLIST_PATH}`)
279
+ }
280
+ log('已卸载。采集已停,此刻起的消息永久丢失;重装:lark-relay install')
281
+ log('日志与事件仍在 ~/.lark-relay/,需要时自行清理')
282
+ return 0
283
+ }
284
+
285
+ // 只改了代码、plist 无需变时的快路径
286
+ function restart({ log = () => {} } = {}) {
287
+ requireDarwin()
288
+ if (!isLoaded()) die('服务未加载,先 lark-relay install', 2)
289
+ requireSudo('restart')
290
+ sudo(['launchctl', 'kickstart', '-k', `system/${LABEL}`])
291
+ const s = waitRunning()
292
+ if (s.state !== 'running') die(`重启后未运行:state=${s.state || '未加载'}`)
293
+ log(`✓ 已重启(pid ${s.pid})`)
294
+ return 0
295
+ }
296
+
297
+ module.exports = { LABEL, PLIST_PATH, install, uninstall, restart, serviceState, isLoaded, buildPlist, buildPath }
package/lib/status.js CHANGED
@@ -1,7 +1,6 @@
1
1
  'use strict'
2
2
 
3
3
  // status:一眼看清全局 —— 谁在跑、各 app 收了多少、游标在哪。
4
- const { execFileSync } = require('child_process')
5
4
  const path = require('path')
6
5
  const store = require('./store')
7
6
  const larkcli = require('./larkcli')
@@ -17,24 +16,13 @@ function ago(ms) {
17
16
  return `${Math.round(s / 86400)}d ago`
18
17
  }
19
18
 
20
- const COLLECT_LABEL = process.env.LR_COLLECT_LABEL || 'com.adaex.lark-relay-collect'
19
+ // label 与 launchd 查询都归 service.js,不在两处各定义一份
20
+ // (改名时漏掉一处就会「服务正常却报没在跑」)
21
+ const { LABEL: COLLECT_LABEL, serviceState: rawServiceState } = require('./service')
21
22
 
22
23
  function serviceState() {
23
- try {
24
- // stderr 必须 ignore:launchctl 对不存在的服务把 "Bad request" 写到 stderr
25
- const out = execFileSync('launchctl', ['print', `system/${COLLECT_LABEL}`], {
26
- encoding: 'utf8',
27
- stdio: ['ignore', 'pipe', 'ignore'],
28
- })
29
- // state 可以是多词值,如 not running
30
- const state = (out.match(/^\s*state\s*=\s*(.+)$/m) || [])[1]
31
- const pid = Number((out.match(/^\s*pid\s*=\s*(\d+)/m) || [])[1]) || null
32
- const s = (state || '').trim()
33
- return { state: s === 'running' ? 'active' : s || 'inactive', pid }
34
- } catch (err) {
35
- if (err.status === 113) return { state: 'inactive', pid: null }
36
- throw err
37
- }
24
+ const s = rawServiceState()
25
+ return { state: s.state === 'running' ? 'active' : s.state || 'inactive', pid: s.pid }
38
26
  }
39
27
 
40
28
  // 枚举游标:cursors/<name>/<app>。活消费者判定复用 take 的锁,不另造注册表
@@ -104,7 +92,7 @@ async function buildStatus() {
104
92
  pid,
105
93
  profiles,
106
94
  profileError,
107
- hint: `sudo launchctl bootstrap system /Library/LaunchDaemons/${COLLECT_LABEL}.plist`,
95
+ hint: 'sudo -v && lark-relay install',
108
96
  },
109
97
  apps: rows,
110
98
  cursorsDir: paths.cursors,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-relay",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "description": "Lark event relay: collect events to disk, take a batch when you need it.",
5
5
  "keywords": [
6
6
  "lark",