herdr-remote 0.2.17 → 0.2.19
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/herdr-remote.js +3 -221
- package/dist/agent-status.js +130 -0
- package/dist/binary-plist.js +157 -0
- package/dist/cli.js +255 -0
- package/dist/config.js +270 -0
- package/dist/connector/agent-watch.js +110 -0
- package/dist/connector/font-server.js +165 -0
- package/dist/connector/host-connector.js +430 -0
- package/dist/connector/lock.js +66 -0
- package/dist/connector/main.js +18 -0
- package/dist/connector/sessions.js +366 -0
- package/dist/connector/update-report.js +67 -0
- package/dist/connector/wire.js +42 -0
- package/{src → dist}/exit-codes.js +1 -4
- package/dist/font-subset.js +110 -0
- package/dist/herdr-api.js +246 -0
- package/dist/herdr-command.js +223 -0
- package/dist/herdr-plugin.js +116 -0
- package/dist/herdr-server.js +189 -0
- package/dist/i18n/en.js +223 -0
- package/dist/i18n/index.js +48 -0
- package/dist/i18n/zh.js +222 -0
- package/dist/keepalive/detached.js +78 -0
- package/dist/keepalive/environment.js +44 -0
- package/dist/keepalive/index.js +110 -0
- package/dist/keepalive/launchd.js +117 -0
- package/dist/keepalive/systemd.js +108 -0
- package/dist/keepalive/types.js +1 -0
- package/dist/lib/process.js +12 -0
- package/dist/lifecycle.js +59 -0
- package/dist/net-interfaces.js +73 -0
- package/dist/pasted-files.js +115 -0
- package/dist/paths.js +62 -0
- package/dist/pty-session.js +96 -0
- package/dist/relay-client.js +106 -0
- package/dist/relay-urls.js +99 -0
- package/dist/runtime.js +106 -0
- package/dist/service.js +260 -0
- package/dist/settings-model.js +298 -0
- package/dist/socket-discovery.js +48 -0
- package/dist/supervisor.js +319 -0
- package/dist/terminal-font/deps.js +73 -0
- package/dist/terminal-font/files.js +354 -0
- package/dist/terminal-font/formats.js +335 -0
- package/dist/terminal-font/index.js +187 -0
- package/dist/terminal-font/readers.js +316 -0
- package/dist/terminal-font/terminals.js +128 -0
- package/dist/terminal-palette.js +339 -0
- package/dist/tui/App.js +195 -0
- package/dist/tui/api.js +79 -0
- package/dist/tui/components/TextField.js +50 -0
- package/dist/tui/components/common.js +80 -0
- package/dist/tui/index.js +36 -0
- package/dist/tui/mouse/index.js +164 -0
- package/dist/tui/mouse/protocol.js +96 -0
- package/dist/tui/mouse/source.js +74 -0
- package/dist/tui/screens/About.js +166 -0
- package/dist/tui/screens/Herdr.js +74 -0
- package/dist/tui/screens/Keepalive.js +84 -0
- package/dist/tui/screens/Overview.js +36 -0
- package/dist/tui/screens/Pair.js +74 -0
- package/dist/tui/screens/Relay.js +242 -0
- package/dist/tui/screens/Services.js +63 -0
- package/dist/tui/screens/Wizard.js +235 -0
- package/dist/tui/terminal.js +39 -0
- package/dist/tui/theme.js +33 -0
- package/dist/updater.js +382 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +10 -9
- package/dist/tui.mjs +0 -4613
- package/src/agent-status.js +0 -139
- package/src/config.js +0 -454
- package/src/herdr-api.js +0 -244
- package/src/herdr-command.js +0 -231
- package/src/herdr-plugin.js +0 -130
- package/src/herdr-server.js +0 -201
- package/src/host-connector.js +0 -927
- package/src/i18n/en.js +0 -240
- package/src/i18n/index.js +0 -56
- package/src/i18n/zh.js +0 -239
- package/src/keepalive.js +0 -460
- package/src/lifecycle.js +0 -62
- package/src/net-interfaces.js +0 -76
- package/src/pasted-files.js +0 -176
- package/src/pty-session.js +0 -92
- package/src/service.js +0 -516
- package/src/settings-model.js +0 -323
- package/src/socket-discovery.js +0 -60
- package/src/state.js +0 -47
- package/src/supervisor.js +0 -250
- package/src/terminal-palette.js +0 -325
- package/src/updater.js +0 -394
package/dist/i18n/zh.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// 简体中文文案。键名必须与 en.ts 完全一致,由类型和 tests/i18n.test.js 校验。
|
|
2
|
+
const zh = {
|
|
3
|
+
'app.name': 'Herdr Remote',
|
|
4
|
+
'app.tagline': '在浏览器中使用 Herdr 工作区',
|
|
5
|
+
'common.yes': '是',
|
|
6
|
+
'common.no': '否',
|
|
7
|
+
'common.on': '开',
|
|
8
|
+
'common.off': '关',
|
|
9
|
+
'common.none': '无',
|
|
10
|
+
'common.auto': '自动',
|
|
11
|
+
'common.unknown': '未知',
|
|
12
|
+
'common.running': '运行中',
|
|
13
|
+
'common.stopped': '已停止',
|
|
14
|
+
'common.connected': '已连接',
|
|
15
|
+
'common.disconnected': '未连接',
|
|
16
|
+
'common.installed': '已安装',
|
|
17
|
+
'common.notInstalled': '未安装',
|
|
18
|
+
'common.enabled': '已启用',
|
|
19
|
+
'common.disabled': '已停用',
|
|
20
|
+
'common.loading': '处理中…',
|
|
21
|
+
'common.save': '保存设置',
|
|
22
|
+
'common.saved': '配置已保存至 {path}',
|
|
23
|
+
'common.back': '返回',
|
|
24
|
+
'common.cancel': '取消',
|
|
25
|
+
'common.confirm': '确认',
|
|
26
|
+
'common.pid': 'PID {pid}',
|
|
27
|
+
'common.notApplicable': '不适用',
|
|
28
|
+
'nav.overview': '概览',
|
|
29
|
+
'nav.pair': '配对设备',
|
|
30
|
+
'nav.services': '服务',
|
|
31
|
+
'nav.relay': 'Relay',
|
|
32
|
+
'nav.keepalive': '保活',
|
|
33
|
+
'nav.herdr': 'Herdr',
|
|
34
|
+
'nav.about': '语言与关于',
|
|
35
|
+
'mode.local': '仅本机',
|
|
36
|
+
'mode.lan': '局域网 / Tailscale',
|
|
37
|
+
'mode.official': '官方 Relay',
|
|
38
|
+
'mode.remote': '自建 Relay',
|
|
39
|
+
'mode.local.description': '监听 127.0.0.1,仅本机可访问。',
|
|
40
|
+
'mode.lan.description': '监听 0.0.0.0,局域网与 Tailscale 设备可访问。',
|
|
41
|
+
'mode.official.description': '使用官方 Relay 跨网访问,无需自建服务器。',
|
|
42
|
+
'mode.remote.description': '连接自建 Relay 跨网访问。',
|
|
43
|
+
'overview.title': '状态',
|
|
44
|
+
'overview.mode': '访问方式',
|
|
45
|
+
'overview.relay': 'Relay',
|
|
46
|
+
'overview.host': '主机连接器',
|
|
47
|
+
'overview.socket': 'Herdr 套接字',
|
|
48
|
+
'overview.webUrl': 'Web 界面',
|
|
49
|
+
'overview.keepalive': '保活服务',
|
|
50
|
+
'overview.devices': '浏览器',
|
|
51
|
+
'overview.hosts': '工作站',
|
|
52
|
+
'overview.uptime': 'Relay 运行时间',
|
|
53
|
+
'overview.relayLocal': '本地,{bind}:{port}',
|
|
54
|
+
'overview.relayRemote': '远程,{url}',
|
|
55
|
+
'overview.socketMissing': '未找到,请确认 Herdr 是否运行',
|
|
56
|
+
'overview.unreachable': '无法访问:{message}',
|
|
57
|
+
'overview.notStarted': '服务未运行,可前往「服务」启动。',
|
|
58
|
+
'pair.title': '配对设备',
|
|
59
|
+
'pair.generate': '生成配对码',
|
|
60
|
+
'pair.regenerate': '重新生成',
|
|
61
|
+
'pair.working': '生成配对码中…',
|
|
62
|
+
'pair.code': '配对码',
|
|
63
|
+
'pair.url': '访问地址',
|
|
64
|
+
'pair.expires': '剩余 {minutes}(至 {time})',
|
|
65
|
+
'pair.expired': '配对码已过期,需重新生成。',
|
|
66
|
+
'pair.instructions': '打开链接并输入配对码。',
|
|
67
|
+
'pair.qrHint': '扫描二维码打开链接。',
|
|
68
|
+
'pair.qrUnavailable': '终端宽度不足,无法显示二维码。',
|
|
69
|
+
'pair.failed': '生成配对码失败:{message}',
|
|
70
|
+
'pair.hostOffline': 'Relay 未连接工作站,需先启动服务。',
|
|
71
|
+
'services.title': '服务',
|
|
72
|
+
'services.start': '启动服务',
|
|
73
|
+
'services.stop': '停止服务',
|
|
74
|
+
'services.restart': '重启服务',
|
|
75
|
+
'services.started': '服务已启动。',
|
|
76
|
+
'services.stopped': '服务已停止。',
|
|
77
|
+
'services.restarted': '服务已重启。',
|
|
78
|
+
'services.startFailed': '启动服务失败:{message}',
|
|
79
|
+
'services.stopFailed': '停止服务失败:{message}',
|
|
80
|
+
'services.unsavedBlocked': '配置未保存,请先保存再重启。',
|
|
81
|
+
'services.managedNotice': '由 {manager} 托管,已通过保活服务执行。',
|
|
82
|
+
'services.logs': '最近日志',
|
|
83
|
+
'services.logEmpty': '暂无日志。',
|
|
84
|
+
'services.logRelay': 'Relay',
|
|
85
|
+
'services.logHost': '主机连接器',
|
|
86
|
+
'relay.title': 'Relay 设置',
|
|
87
|
+
'relay.settings': '设置',
|
|
88
|
+
'relay.locked': '固定',
|
|
89
|
+
'relay.officialFixed': '官方 relay 地址固定,不可修改。',
|
|
90
|
+
'relay.officialHint': '官方 relay:地址固定,无需密码。',
|
|
91
|
+
'relay.password': 'Relay 密码',
|
|
92
|
+
'relay.passwordHint': '须与 Relay 端的 RELAY_PASSWORD 一致。留空为公用 Relay。',
|
|
93
|
+
'relay.passwordSet': '已设置',
|
|
94
|
+
'relay.passwordEmpty': '未设置(公用)',
|
|
95
|
+
'relay.passwordSaved': 'Relay 密码已保存,重启服务生效。',
|
|
96
|
+
'relay.showPassword': '显示密码',
|
|
97
|
+
'relay.hidePassword': '隐藏密码',
|
|
98
|
+
'relay.identity': '工作站标识',
|
|
99
|
+
'relay.regenerate': '重新生成标识',
|
|
100
|
+
'relay.regenerated': '已生成新标识,重启后重新注册。',
|
|
101
|
+
'relay.envSnippet': 'Relay 启动命令',
|
|
102
|
+
'relay.envHint': '在 Relay 主机执行此命令。',
|
|
103
|
+
'relay.test': '测试 Relay 连接',
|
|
104
|
+
'relay.testOk': 'Relay 可访问:版本 {version},已连接 {hosts} 台工作站。',
|
|
105
|
+
'relay.testFailed': 'Relay 无法访问:{message}',
|
|
106
|
+
'relay.selectAddress': '选择公布地址',
|
|
107
|
+
'relay.listenAddress': '监听地址',
|
|
108
|
+
'relay.listenAddressHint': '服务器绑定地址;浏览器访问下方公布地址。',
|
|
109
|
+
'relay.addressTailscale': 'Tailscale',
|
|
110
|
+
'relay.addressLan': '局域网',
|
|
111
|
+
'relay.addressVirtual': '虚拟网卡',
|
|
112
|
+
'relay.addressLoopback': '回环',
|
|
113
|
+
'relay.noAddresses': '未找到非回环地址。',
|
|
114
|
+
'relay.docsHint': '自建指南:docs/self-hosted-relay.zh-CN.md',
|
|
115
|
+
'keepalive.title': '后台保活服务',
|
|
116
|
+
'keepalive.manager': '管理方式',
|
|
117
|
+
'keepalive.state': '状态',
|
|
118
|
+
'keepalive.install': '安装并启动',
|
|
119
|
+
'keepalive.uninstall': '停止并移除',
|
|
120
|
+
'keepalive.restart': '重启保活服务',
|
|
121
|
+
'keepalive.installed': '已通过 {manager} 安装保活服务。',
|
|
122
|
+
'keepalive.uninstalled': '保活服务已移除。',
|
|
123
|
+
'keepalive.restarted': '保活服务已重启。',
|
|
124
|
+
'keepalive.failed': '保活操作失败:{message}',
|
|
125
|
+
'keepalive.unitPath': '单元文件',
|
|
126
|
+
'keepalive.logsHint': '查看日志:{command}',
|
|
127
|
+
'keepalive.linger': '开机自启',
|
|
128
|
+
'keepalive.lingerEnabled': '已开启开机自启。',
|
|
129
|
+
'keepalive.lingerDisabled': '未开启开机自启(仅登录期间运行)。',
|
|
130
|
+
'keepalive.enableLinger': '开启开机自启',
|
|
131
|
+
'keepalive.lingerDone': '已为 {username} 开启开机自启。',
|
|
132
|
+
'keepalive.fallbackNote': '无系统服务管理器,使用后台守护进程,重启后不自动恢复。',
|
|
133
|
+
'herdr.title': 'Herdr 集成',
|
|
134
|
+
'herdr.socketPath': '套接字路径',
|
|
135
|
+
'herdr.args': '附加参数',
|
|
136
|
+
'herdr.autoStartHint': '关:Herdr 只在你手动运行,或在浏览器里确认后才启动。开:herdr-remote 每次启动(包括开机)时一并启动 Herdr。下次 herdr-remote 启动时生效。',
|
|
137
|
+
'herdr.cliMissing': '未找到 herdr 命令,可将 HERDR_BIN_PATH 设为其完整路径。',
|
|
138
|
+
'herdr.version': 'Herdr 版本',
|
|
139
|
+
'herdr.versionOutdated': 'Herdr {version} 低于 {minimum},请运行 “herdr update”:在该版本之前,浏览器窗口的标题、后台机器激活与大段粘贴都会出问题。',
|
|
140
|
+
'about.title': '语言与关于',
|
|
141
|
+
'about.language': '界面语言',
|
|
142
|
+
'about.languageAuto': '跟随系统({detected})',
|
|
143
|
+
'about.languageZh': '中文',
|
|
144
|
+
'about.languageEn': 'English',
|
|
145
|
+
'about.version': '版本',
|
|
146
|
+
'about.configPath': '配置文件',
|
|
147
|
+
'about.statePath': '状态目录',
|
|
148
|
+
'about.relayPackage': 'Relay 包',
|
|
149
|
+
'about.docs': '自建指南:docs/self-hosted-relay.zh-CN.md',
|
|
150
|
+
'wizard.title': '首次设置',
|
|
151
|
+
'wizard.step': '第 {current} / {total} 步',
|
|
152
|
+
'wizard.languageTitle': '选择语言',
|
|
153
|
+
'wizard.accessTitle': '选择访问方式',
|
|
154
|
+
'wizard.accessHint': '后续可在 Relay 页面修改。',
|
|
155
|
+
'wizard.addressTitle': '选择访问地址',
|
|
156
|
+
'wizard.relayTitle': 'Relay 服务器',
|
|
157
|
+
'wizard.relayUrlLabel': 'Relay 地址(wss://…)',
|
|
158
|
+
'wizard.relayHint': 'Relay 需先运行。搭建指南见 docs/self-hosted-relay.zh-CN.md。',
|
|
159
|
+
'wizard.passwordTitle': 'Relay 密码',
|
|
160
|
+
'wizard.passwordHint': 'Relay 的 RELAY_PASSWORD。未设留空。',
|
|
161
|
+
'wizard.finishTitle': '完成',
|
|
162
|
+
'wizard.finishHint': '配置将保存至 {path}。',
|
|
163
|
+
'wizard.startNow': '立即启动服务',
|
|
164
|
+
'wizard.installKeepalive': '后台保活',
|
|
165
|
+
'wizard.finish': '保存并完成',
|
|
166
|
+
'field.mode': '访问方式',
|
|
167
|
+
'field.port': 'Relay 端口',
|
|
168
|
+
'field.lanHost': '浏览器地址',
|
|
169
|
+
'field.remoteUrl': 'Relay 地址',
|
|
170
|
+
'field.publicUrl': '浏览器地址(覆盖)',
|
|
171
|
+
'field.socketPath': 'Herdr 套接字路径',
|
|
172
|
+
'field.herdrArgs': 'Herdr 参数',
|
|
173
|
+
'field.herdrAutoStart': '随服务启动 Herdr',
|
|
174
|
+
'field.language': '语言',
|
|
175
|
+
'field.keepalive': '保活管理方式',
|
|
176
|
+
'placeholder.autoDiscovered': '自动探测',
|
|
177
|
+
'placeholder.none': '无',
|
|
178
|
+
'error.invalidMode': '未知的访问方式。',
|
|
179
|
+
'error.invalidPort': '端口须为 1 到 65535 之间的数字。',
|
|
180
|
+
'error.invalidLanHost': '浏览器地址须为可访问局域网或 Tailscale 地址,不能为回环或 0.0.0.0。',
|
|
181
|
+
'error.invalidRelayUrl': 'Relay 地址须以 wss://、ws://、https:// 或 http:// 开头。',
|
|
182
|
+
'error.invalidPublicUrl': '浏览器地址须以 https:// 或 http:// 开头,不能为 0.0.0.0 或 ::。',
|
|
183
|
+
'error.invalidLanguage': '未知的语言。',
|
|
184
|
+
'error.invalidKeepalive': '未知的保活管理方式。',
|
|
185
|
+
'error.unknownField': '未知的设置项。',
|
|
186
|
+
'error.remoteUrlRequired': '自建 Relay 模式须填写 Relay 地址。',
|
|
187
|
+
'error.removedHerdrArg': 'Herdr 0.9.0 已移除 --no-session,请从附加参数中移除。',
|
|
188
|
+
'error.saveFailed': '保存配置失败:{message}',
|
|
189
|
+
'hint.navigate': '↑↓ 移动',
|
|
190
|
+
'hint.select': '↵ 选择',
|
|
191
|
+
'hint.edit': '↵ 编辑',
|
|
192
|
+
'hint.tabs': '← → 切换页面',
|
|
193
|
+
'hint.back': 'esc 返回',
|
|
194
|
+
'hint.quit': 'q 退出',
|
|
195
|
+
'hint.mouseOn': 'm 关闭鼠标',
|
|
196
|
+
'hint.mouseOff': 'm 开启鼠标',
|
|
197
|
+
'hint.mouseUnsupported': '不支持鼠标',
|
|
198
|
+
'hint.restartRequired': '重启服务生效。',
|
|
199
|
+
'hint.unsavedChanges': '有未保存修改(按 s 保存)。',
|
|
200
|
+
'hint.save': 's 保存',
|
|
201
|
+
'hint.editing': '↵ 确认 · esc 取消',
|
|
202
|
+
'update.title': '更新',
|
|
203
|
+
'update.banner': 'herdr-remote {latest} 已发布(当前 {current}),按 7 进入「语言与关于」,在更新一行按 Enter 安装。',
|
|
204
|
+
'update.check': '检查更新',
|
|
205
|
+
'update.checking': '查询 npm 中…',
|
|
206
|
+
'update.upToDate': '已是最新({version})',
|
|
207
|
+
'update.available': '新版本 {version} 可用(按 Enter 安装)',
|
|
208
|
+
'update.updating': '安装 {version} 中…',
|
|
209
|
+
'update.updatingRetry': '安装 {version} 中…(npm 尚未同步,第 {attempt} 次尝试)',
|
|
210
|
+
'update.done': '已更新至 {version}',
|
|
211
|
+
'update.restartHint': '更新已安装,重启 herdr-remote 生效。',
|
|
212
|
+
'update.errorNetwork': '所有 npm registry 均无法连接',
|
|
213
|
+
'update.errorNetworkDetail': '无 registry 应答:{message}',
|
|
214
|
+
'update.errorFailed': '更新失败,可手动执行:npm install -g herdr-remote@{version} --prefer-online',
|
|
215
|
+
'update.errorFailedDetail': 'npm:{message}',
|
|
216
|
+
'update.errorNotYetPublished': 'npm 还没同步到 {version}(刚发布的几分钟内常见),稍后按 Enter 重试。',
|
|
217
|
+
'update.errorNotApplied': 'npm 报告成功,但安装的仍是 {installed}',
|
|
218
|
+
'update.mirrorBehind': '{registries} 仍是旧版本,将从 {source} 安装',
|
|
219
|
+
'update.cannot.source': '源码目录运行,请使用 git 更新',
|
|
220
|
+
'update.cannot.linked': 'npm link 工作副本,由 npm link 管理',
|
|
221
|
+
};
|
|
222
|
+
export default zh;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The fallback where no service manager is usable: a detached copy of our own
|
|
2
|
+
// supervisor, tracked by a pid file. It does not survive a reboot.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { ensureDir, readJson, writeJsonAtomic } from 'herdr-remote-relay/state';
|
|
7
|
+
import { pidAlive } from '../lib/process.js';
|
|
8
|
+
import { logPath, PACKAGE_ROOT, stateDir } from '../paths.js';
|
|
9
|
+
import { cliEntryPoint, serviceEnvironment } from './environment.js';
|
|
10
|
+
function fallbackPidPath() {
|
|
11
|
+
return path.join(stateDir(), 'supervisor.pid');
|
|
12
|
+
}
|
|
13
|
+
function readPid() {
|
|
14
|
+
const record = readJson(fallbackPidPath(), {});
|
|
15
|
+
return Number.isInteger(record.pid) ? record.pid : null;
|
|
16
|
+
}
|
|
17
|
+
function start() {
|
|
18
|
+
const running = readPid();
|
|
19
|
+
if (pidAlive(running))
|
|
20
|
+
return { ok: true, alreadyRunning: true, pid: running };
|
|
21
|
+
ensureDir(stateDir());
|
|
22
|
+
const logFd = fs.openSync(logPath('supervisor'), 'a');
|
|
23
|
+
try {
|
|
24
|
+
const child = spawn(process.execPath, [cliEntryPoint(), 'run', '--daemon'], {
|
|
25
|
+
cwd: PACKAGE_ROOT,
|
|
26
|
+
env: { ...process.env, HERDR_REMOTE_SERVICE: '1', ...serviceEnvironment() },
|
|
27
|
+
detached: true,
|
|
28
|
+
stdio: ['ignore', logFd, logFd],
|
|
29
|
+
});
|
|
30
|
+
child.unref();
|
|
31
|
+
writeJsonAtomic(fallbackPidPath(), { pid: child.pid, startedAt: new Date().toISOString() });
|
|
32
|
+
return { ok: true, pid: child.pid, note: 'this fallback does not survive a reboot' };
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
fs.closeSync(logFd);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function stopAndForget() {
|
|
39
|
+
const pid = readPid();
|
|
40
|
+
if (pid !== null && pidAlive(pid)) {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, 'SIGTERM');
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Exited between the check and the signal.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
fs.rmSync(fallbackPidPath(), { force: true });
|
|
49
|
+
return { ok: true, stoppedPid: pid };
|
|
50
|
+
}
|
|
51
|
+
export const detached = {
|
|
52
|
+
name: 'supervisor',
|
|
53
|
+
status() {
|
|
54
|
+
const pid = readPid();
|
|
55
|
+
const alive = pidAlive(pid);
|
|
56
|
+
return {
|
|
57
|
+
manager: 'supervisor',
|
|
58
|
+
installed: alive,
|
|
59
|
+
active: alive,
|
|
60
|
+
enabled: false,
|
|
61
|
+
pid: alive ? pid : null,
|
|
62
|
+
unitPath: fallbackPidPath(),
|
|
63
|
+
state: alive ? 'running' : 'stopped',
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
install: start,
|
|
67
|
+
uninstall: stopAndForget,
|
|
68
|
+
restart() {
|
|
69
|
+
stopAndForget();
|
|
70
|
+
return start();
|
|
71
|
+
},
|
|
72
|
+
stop() {
|
|
73
|
+
stopAndForget();
|
|
74
|
+
},
|
|
75
|
+
logsHint() {
|
|
76
|
+
return `tail -f ${logPath('supervisor')}`;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// What a supervised copy needs from the shell that installed it.
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { findHerdrCommand } from '../herdr-command.js';
|
|
5
|
+
import { PACKAGE_ROOT } from '../paths.js';
|
|
6
|
+
export function cliEntryPoint() {
|
|
7
|
+
return path.join(PACKAGE_ROOT, 'bin', 'herdr-remote.js');
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* `PATH` for the unit: the one that is resolving commands right now, plus the
|
|
11
|
+
* directory Herdr was found in.
|
|
12
|
+
*
|
|
13
|
+
* A service manager does not inherit the shell's environment. `systemd --user`
|
|
14
|
+
* hands a unit something close to `/usr/local/bin:/usr/bin:/bin` and `launchd`
|
|
15
|
+
* is no more generous, so a copy that works from a terminal loses `~/.local/bin`
|
|
16
|
+
* — and with it `herdr` — the moment it is installed as a service. Freezing the
|
|
17
|
+
* installing shell's `PATH` into the unit keeps the supervised copy able to find
|
|
18
|
+
* Herdr, and Herdr able to find the tools it spawns in turn.
|
|
19
|
+
*/
|
|
20
|
+
export function servicePath({ env = process.env, herdrCommand = null, } = {}) {
|
|
21
|
+
const entries = String(env.PATH || '')
|
|
22
|
+
.split(path.delimiter)
|
|
23
|
+
.map((entry) => entry.trim())
|
|
24
|
+
.filter(Boolean);
|
|
25
|
+
// The override already pins the exact binary; this is only so the session
|
|
26
|
+
// itself can still reach it by name.
|
|
27
|
+
if (herdrCommand)
|
|
28
|
+
entries.push(path.dirname(herdrCommand));
|
|
29
|
+
return [...new Set(entries)].join(path.delimiter);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* What has to be written into the unit file for a supervised start to behave
|
|
33
|
+
* like the one the user just ran by hand.
|
|
34
|
+
*/
|
|
35
|
+
export function serviceEnvironment({ env = process.env, home = os.homedir(), directories, } = {}) {
|
|
36
|
+
const environment = {};
|
|
37
|
+
const herdr = findHerdrCommand({ env, home, ...(directories ? { directories } : {}) });
|
|
38
|
+
if (herdr.found)
|
|
39
|
+
environment.HERDR_BIN_PATH = herdr.command;
|
|
40
|
+
const searchPath = servicePath({ env, herdrCommand: herdr.found ? herdr.command : null });
|
|
41
|
+
if (searchPath)
|
|
42
|
+
environment.PATH = searchPath;
|
|
43
|
+
return environment;
|
|
44
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Keep-alive integration.
|
|
2
|
+
//
|
|
3
|
+
// "Start the services" and "keep the services running" are different problems:
|
|
4
|
+
// a detached spawn dies with the first crash and never comes back. This module
|
|
5
|
+
// hands supervision to the platform's service manager where one exists
|
|
6
|
+
// (systemd --user on Linux, launchd on macOS) and falls back to a detached copy
|
|
7
|
+
// of our own supervisor where neither does.
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { loadConfig } from '../config.js';
|
|
13
|
+
import { detached } from './detached.js';
|
|
14
|
+
import { launchd } from './launchd.js';
|
|
15
|
+
import { systemd } from './systemd.js';
|
|
16
|
+
export { serviceEnvironment, servicePath } from './environment.js';
|
|
17
|
+
export { escapeXml, renderLaunchdPlist } from './launchd.js';
|
|
18
|
+
export { renderSystemdUnit, systemdEnvironmentLine } from './systemd.js';
|
|
19
|
+
/**
|
|
20
|
+
* Look for an executable on PATH without going through a shell: `spawnSync`
|
|
21
|
+
* with `shell: true` concatenates rather than escapes its arguments, and Node
|
|
22
|
+
* now warns about it on every call.
|
|
23
|
+
*/
|
|
24
|
+
function commandExists(command) {
|
|
25
|
+
return (process.env.PATH || '')
|
|
26
|
+
.split(path.delimiter)
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.some((directory) => {
|
|
29
|
+
try {
|
|
30
|
+
fs.accessSync(path.join(directory, command), fs.constants.X_OK);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/** Which manager to use given the platform, the user's preference and reality. */
|
|
39
|
+
export function detectManager(preference = 'auto') {
|
|
40
|
+
if (preference && preference !== 'auto')
|
|
41
|
+
return preference;
|
|
42
|
+
if (process.platform === 'linux') {
|
|
43
|
+
// `systemctl --user` needs a user bus; containers and bare TTY logins often
|
|
44
|
+
// have systemd installed but no session bus, where it would fail at runtime.
|
|
45
|
+
if (commandExists('systemctl') &&
|
|
46
|
+
(process.env.DBUS_SESSION_BUS_ADDRESS || process.env.XDG_RUNTIME_DIR)) {
|
|
47
|
+
return 'systemd';
|
|
48
|
+
}
|
|
49
|
+
return 'supervisor';
|
|
50
|
+
}
|
|
51
|
+
if (process.platform === 'darwin') {
|
|
52
|
+
return commandExists('launchctl') ? 'launchd' : 'supervisor';
|
|
53
|
+
}
|
|
54
|
+
return 'supervisor';
|
|
55
|
+
}
|
|
56
|
+
function managerOf(config) {
|
|
57
|
+
return detectManager(config.keepalive?.manager || 'auto');
|
|
58
|
+
}
|
|
59
|
+
/** The backend for `config`. With keep-alive off, uninstalling still cleans up the fallback. */
|
|
60
|
+
function backendOf(config) {
|
|
61
|
+
const manager = managerOf(config);
|
|
62
|
+
if (manager === 'systemd')
|
|
63
|
+
return systemd;
|
|
64
|
+
if (manager === 'launchd')
|
|
65
|
+
return launchd;
|
|
66
|
+
return detached;
|
|
67
|
+
}
|
|
68
|
+
export function status(config = loadConfig()) {
|
|
69
|
+
if (managerOf(config) === 'none')
|
|
70
|
+
return { manager: 'none', installed: false, active: false, enabled: false };
|
|
71
|
+
return backendOf(config).status();
|
|
72
|
+
}
|
|
73
|
+
export function install(config = loadConfig()) {
|
|
74
|
+
if (managerOf(config) === 'none')
|
|
75
|
+
throw new Error('keep-alive is disabled in the configuration');
|
|
76
|
+
const backend = backendOf(config);
|
|
77
|
+
return { manager: backend.name, ...backend.install() };
|
|
78
|
+
}
|
|
79
|
+
export function uninstall(config = loadConfig()) {
|
|
80
|
+
const backend = backendOf(config);
|
|
81
|
+
return { manager: backend.name, ...backend.uninstall() };
|
|
82
|
+
}
|
|
83
|
+
/** Restart whatever manages the services, so config edits take effect. */
|
|
84
|
+
export function restart(config = loadConfig()) {
|
|
85
|
+
const backend = backendOf(config);
|
|
86
|
+
return { manager: backend.name, ...backend.restart() };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Stop the managed services. Killing the child pids directly would only make
|
|
90
|
+
* the service manager start them again, so an installed manager is asked to
|
|
91
|
+
* stop instead.
|
|
92
|
+
*/
|
|
93
|
+
export function stopManaged(config = loadConfig()) {
|
|
94
|
+
const current = status(config);
|
|
95
|
+
if (current.manager === 'none' || (!current.installed && !current.active))
|
|
96
|
+
return { managed: false };
|
|
97
|
+
backendOf(config).stop();
|
|
98
|
+
return { managed: true, manager: current.manager };
|
|
99
|
+
}
|
|
100
|
+
export function enableLinger() {
|
|
101
|
+
const username = os.userInfo().username;
|
|
102
|
+
const result = spawnSync('loginctl', ['enable-linger', username], { encoding: 'utf8' });
|
|
103
|
+
if (result.status !== 0) {
|
|
104
|
+
throw new Error(String(result.stderr || '').trim() || 'loginctl enable-linger failed');
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, username };
|
|
107
|
+
}
|
|
108
|
+
export function logsHint(config = loadConfig()) {
|
|
109
|
+
return backendOf(config).logsHint();
|
|
110
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// launchd: a LaunchAgent that runs `herdr-remote run` and keeps it alive.
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { ensureDir } from 'herdr-remote-relay/state';
|
|
7
|
+
import { logPath, stateDir } from '../paths.js';
|
|
8
|
+
import { cliEntryPoint, serviceEnvironment } from './environment.js';
|
|
9
|
+
const LAUNCHD_LABEL = 'dev.herdr.remote';
|
|
10
|
+
function launchdPlistPath() {
|
|
11
|
+
return path.join(os.homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
|
|
12
|
+
}
|
|
13
|
+
export function escapeXml(value) {
|
|
14
|
+
return String(value)
|
|
15
|
+
.replace(/&/g, '&')
|
|
16
|
+
.replace(/</g, '<')
|
|
17
|
+
.replace(/>/g, '>')
|
|
18
|
+
.replace(/"/g, '"')
|
|
19
|
+
.replace(/'/g, ''');
|
|
20
|
+
}
|
|
21
|
+
export function renderLaunchdPlist({ nodePath = process.execPath, entryPoint = cliEntryPoint(), label = LAUNCHD_LABEL, outLog = logPath('supervisor'), errLog = logPath('supervisor'), environment = {}, } = {}) {
|
|
22
|
+
const environmentEntries = Object.entries(environment)
|
|
23
|
+
.map(([key, value]) => ` <key>${escapeXml(key)}</key>\n <string>${escapeXml(value)}</string>`)
|
|
24
|
+
.join('\n');
|
|
25
|
+
const environmentBlock = environmentEntries
|
|
26
|
+
? ` <key>EnvironmentVariables</key>\n <dict>\n${environmentEntries}\n </dict>\n`
|
|
27
|
+
: '';
|
|
28
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
29
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
30
|
+
<plist version="1.0">
|
|
31
|
+
<dict>
|
|
32
|
+
<key>Label</key>
|
|
33
|
+
<string>${escapeXml(label)}</string>
|
|
34
|
+
<key>ProgramArguments</key>
|
|
35
|
+
<array>
|
|
36
|
+
<string>${escapeXml(nodePath)}</string>
|
|
37
|
+
<string>${escapeXml(entryPoint)}</string>
|
|
38
|
+
<string>run</string>
|
|
39
|
+
</array>
|
|
40
|
+
<key>RunAtLoad</key>
|
|
41
|
+
<true/>
|
|
42
|
+
<key>KeepAlive</key>
|
|
43
|
+
<true/>
|
|
44
|
+
<key>ProcessType</key>
|
|
45
|
+
<string>Background</string>
|
|
46
|
+
${environmentBlock} <key>StandardOutPath</key>
|
|
47
|
+
<string>${escapeXml(outLog)}</string>
|
|
48
|
+
<key>StandardErrorPath</key>
|
|
49
|
+
<string>${escapeXml(errLog)}</string>
|
|
50
|
+
</dict>
|
|
51
|
+
</plist>
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
function domainTarget() {
|
|
55
|
+
return `gui/${process.getuid ? process.getuid() : ''}`;
|
|
56
|
+
}
|
|
57
|
+
function serviceTarget() {
|
|
58
|
+
return `${domainTarget()}/${LAUNCHD_LABEL}`;
|
|
59
|
+
}
|
|
60
|
+
function launchctl(args) {
|
|
61
|
+
return spawnSync('launchctl', args, { encoding: 'utf8' });
|
|
62
|
+
}
|
|
63
|
+
/** A launchctl call whose outcome does not matter (bootout of nothing, say). */
|
|
64
|
+
function launchctlQuietly(args) {
|
|
65
|
+
spawnSync('launchctl', args, { stdio: 'ignore' });
|
|
66
|
+
}
|
|
67
|
+
export const launchd = {
|
|
68
|
+
name: 'launchd',
|
|
69
|
+
status() {
|
|
70
|
+
const plistPath = launchdPlistPath();
|
|
71
|
+
if (!fs.existsSync(plistPath))
|
|
72
|
+
return { manager: 'launchd', installed: false, active: false, enabled: false };
|
|
73
|
+
const result = launchctl(['print', serviceTarget()]);
|
|
74
|
+
const output = String(result.stdout || '');
|
|
75
|
+
return {
|
|
76
|
+
manager: 'launchd',
|
|
77
|
+
installed: true,
|
|
78
|
+
active: result.status === 0 && /state = running/.test(output),
|
|
79
|
+
enabled: result.status === 0,
|
|
80
|
+
unitPath: plistPath,
|
|
81
|
+
state: result.status === 0 ? 'loaded' : 'not loaded',
|
|
82
|
+
};
|
|
83
|
+
},
|
|
84
|
+
install() {
|
|
85
|
+
const plistPath = launchdPlistPath();
|
|
86
|
+
ensureDir(path.dirname(plistPath));
|
|
87
|
+
ensureDir(stateDir());
|
|
88
|
+
fs.writeFileSync(plistPath, renderLaunchdPlist({ environment: serviceEnvironment() }), {
|
|
89
|
+
mode: 0o644,
|
|
90
|
+
});
|
|
91
|
+
// bootout first so a re-install picks up the rewritten plist.
|
|
92
|
+
launchctlQuietly(['bootout', serviceTarget()]);
|
|
93
|
+
const result = launchctl(['bootstrap', domainTarget(), plistPath]);
|
|
94
|
+
if (result.status !== 0) {
|
|
95
|
+
throw new Error(`launchctl bootstrap failed: ${String(result.stderr || '').trim()}`);
|
|
96
|
+
}
|
|
97
|
+
launchctlQuietly(['enable', serviceTarget()]);
|
|
98
|
+
return { ok: true, unitPath: plistPath };
|
|
99
|
+
},
|
|
100
|
+
uninstall() {
|
|
101
|
+
const plistPath = launchdPlistPath();
|
|
102
|
+
launchctlQuietly(['bootout', serviceTarget()]);
|
|
103
|
+
fs.rmSync(plistPath, { force: true });
|
|
104
|
+
return { ok: true, unitPath: plistPath };
|
|
105
|
+
},
|
|
106
|
+
restart() {
|
|
107
|
+
launchctlQuietly(['kickstart', '-k', serviceTarget()]);
|
|
108
|
+
return { ok: true };
|
|
109
|
+
},
|
|
110
|
+
stop() {
|
|
111
|
+
launchctlQuietly(['bootout', serviceTarget()]);
|
|
112
|
+
},
|
|
113
|
+
// The plist sends the supervisor's output to its log file.
|
|
114
|
+
logsHint() {
|
|
115
|
+
return `tail -f ${logPath('supervisor')}`;
|
|
116
|
+
},
|
|
117
|
+
};
|