herdr-remote 0.2.0

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/src/i18n/zh.js ADDED
@@ -0,0 +1,218 @@
1
+ 'use strict';
2
+
3
+ // 简体中文文案。键名必须与 en.js 完全一致,由 tests/i18n.test.js 校验。
4
+
5
+ module.exports = {
6
+ 'app.name': 'Herdr Remote',
7
+ 'app.tagline': '在浏览器里远程使用你的 Herdr 工作区',
8
+
9
+ 'common.yes': '是',
10
+ 'common.no': '否',
11
+ 'common.on': '开',
12
+ 'common.off': '关',
13
+ 'common.none': '无',
14
+ 'common.auto': '自动',
15
+ 'common.unknown': '未知',
16
+ 'common.running': '运行中',
17
+ 'common.stopped': '已停止',
18
+ 'common.connected': '已连接',
19
+ 'common.disconnected': '未连接',
20
+ 'common.installed': '已安装',
21
+ 'common.notInstalled': '未安装',
22
+ 'common.enabled': '已启用',
23
+ 'common.disabled': '已停用',
24
+ 'common.loading': '处理中…',
25
+ 'common.save': '保存设置',
26
+ 'common.saved': '配置已保存到 {path}',
27
+ 'common.back': '返回',
28
+ 'common.cancel': '取消',
29
+ 'common.confirm': '确认',
30
+ 'common.pid': '进程 {pid}',
31
+ 'common.notApplicable': '不适用',
32
+
33
+ 'nav.overview': '概览',
34
+ 'nav.pair': '配对设备',
35
+ 'nav.services': '服务',
36
+ 'nav.relay': 'Relay',
37
+ 'nav.keepalive': '保活',
38
+ 'nav.herdr': 'Herdr',
39
+ 'nav.about': '语言与关于',
40
+
41
+ 'mode.local': '仅本机',
42
+ 'mode.lan': '局域网 / Tailscale',
43
+ 'mode.remote': '自建 relay',
44
+ 'mode.local.description': 'relay 监听 127.0.0.1,只有本机浏览器能访问。',
45
+ 'mode.lan.description': 'relay 监听 0.0.0.0(所有网卡),局域网或 tailnet 里的手机都能访问。',
46
+ 'mode.remote.description': '不启动本地 relay。本机连接你自建的 relay,这是从外网访问的唯一方式。',
47
+
48
+ 'overview.title': '状态',
49
+ 'overview.mode': '访问方式',
50
+ 'overview.relay': 'Relay',
51
+ 'overview.host': '主机连接器',
52
+ 'overview.socket': 'Herdr 套接字',
53
+ 'overview.webUrl': 'Web 界面',
54
+ 'overview.keepalive': '保活服务',
55
+ 'overview.devices': '浏览器',
56
+ 'overview.hosts': '工作站',
57
+ 'overview.uptime': 'Relay 运行时长',
58
+ 'overview.relayLocal': '本地,{bind}:{port}',
59
+ 'overview.relayRemote': '远程,{url}',
60
+ 'overview.socketMissing': '未找到 — Herdr 在运行吗?',
61
+ 'overview.unreachable': '无法访问:{message}',
62
+ 'overview.notStarted': '服务未运行,请到「服务」页面启动。',
63
+
64
+ 'pair.title': '配对设备',
65
+ 'pair.generate': '生成一次性配对码',
66
+ 'pair.regenerate': '重新生成配对码',
67
+ 'pair.working': '正在启动服务并申请配对码…',
68
+ 'pair.code': '配对码',
69
+ 'pair.url': '在手机上打开',
70
+ 'pair.expires': '剩余 {minutes}(至 {time})',
71
+ 'pair.expired': '配对码已过期,请重新生成。',
72
+ 'pair.instructions': '打开链接、输入配对码,之后这台浏览器就长期可用了。',
73
+ 'pair.qrHint': '用手机相机扫码即可打开链接。',
74
+ 'pair.qrUnavailable': '终端宽度不足以显示二维码,请使用上面的链接。',
75
+ 'pair.failed': '生成配对码失败:{message}',
76
+ 'pair.hostOffline': 'relay 上还没有工作站连接,请先启动服务。',
77
+
78
+ 'services.title': '服务',
79
+ 'services.start': '启动服务',
80
+ 'services.stop': '停止服务',
81
+ 'services.restart': '重启服务',
82
+ 'services.started': '服务已启动。',
83
+ 'services.stopped': '服务已停止。',
84
+ 'services.restarted': '服务已重启。',
85
+ 'services.startFailed': '启动服务失败:{message}',
86
+ 'services.stopFailed': '停止服务失败:{message}',
87
+ 'services.unsavedBlocked': '有未保存的修改。请先保存,否则服务会用磁盘上的旧配置重启。',
88
+ 'services.managedNotice': '服务由 {manager} 托管,已通过保活服务执行此操作。',
89
+ 'services.logs': '最近日志',
90
+ 'services.logEmpty': '暂无日志输出。',
91
+ 'services.logRelay': 'Relay',
92
+ 'services.logHost': '主机连接器',
93
+
94
+ 'relay.title': 'Relay 设置',
95
+ 'relay.settings': '设置',
96
+ 'relay.password': 'Relay 密码',
97
+ 'relay.passwordHint': '需与 relay 上的 RELAY_PASSWORD 一致。留空表示公用 relay。',
98
+ 'relay.passwordSet': '已设置',
99
+ 'relay.passwordEmpty': '未设置(公用 relay)',
100
+ 'relay.passwordSaved': 'Relay 密码已保存,重启服务后生效。',
101
+ 'relay.showPassword': '显示密码',
102
+ 'relay.hidePassword': '隐藏密码',
103
+ 'relay.identity': '工作站标识',
104
+ 'relay.regenerate': '重新生成工作站标识',
105
+ 'relay.regenerated': '已生成新的工作站标识,重启服务后会重新注册。',
106
+ 'relay.envSnippet': 'relay 服务器启动命令',
107
+ 'relay.envHint': '在 relay 主机上执行这条命令。',
108
+ 'relay.test': '测试 relay 连接',
109
+ 'relay.testOk': 'relay 可访问:版本 {version},已连接 {hosts} 台工作站。',
110
+ 'relay.testFailed': 'relay 无法访问:{message}',
111
+ 'relay.selectAddress': '选择对外公布的地址',
112
+ 'relay.listenAddress': '监听地址',
113
+ 'relay.listenAddressHint': '这是服务器绑定地址;下面的对外公布地址才是浏览器要打开的地址。',
114
+ 'relay.addressTailscale': 'Tailscale',
115
+ 'relay.addressLan': '局域网',
116
+ 'relay.addressVirtual': '虚拟网卡',
117
+ 'relay.addressLoopback': '回环',
118
+ 'relay.noAddresses': '没有找到非回环地址,请先连接网络或启动 Tailscale。',
119
+ 'relay.docsHint': '自建 relay 请参阅 docs/self-hosted-relay.zh-CN.md',
120
+
121
+ 'keepalive.title': '后台保活服务',
122
+ 'keepalive.manager': '管理方式',
123
+ 'keepalive.state': '状态',
124
+ 'keepalive.install': '安装并启动',
125
+ 'keepalive.uninstall': '停止并移除',
126
+ 'keepalive.restart': '重启保活服务',
127
+ 'keepalive.installed': '已通过 {manager} 安装保活服务。',
128
+ 'keepalive.uninstalled': '保活服务已移除。',
129
+ 'keepalive.restarted': '保活服务已重启。',
130
+ 'keepalive.failed': '保活操作失败:{message}',
131
+ 'keepalive.unitPath': '单元文件',
132
+ 'keepalive.logsHint': '查看日志:{command}',
133
+ 'keepalive.linger': '未登录也开机自启',
134
+ 'keepalive.lingerEnabled': '已开启 linger:开机即启动。',
135
+ 'keepalive.lingerDisabled': '未开启 linger,服务只在你登录期间运行。',
136
+ 'keepalive.enableLinger': '开启开机自启',
137
+ 'keepalive.lingerDone': '已为 {username} 开启 linger。',
138
+ 'keepalive.fallbackNote': '系统没有可用的服务管理器,改用内置的后台守护进程,重启电脑后不会自动恢复。',
139
+
140
+ 'herdr.title': 'Herdr 集成',
141
+ 'herdr.socketPath': '套接字路径',
142
+ 'herdr.args': '附加参数',
143
+ 'herdr.plugin': '插件注册',
144
+ 'herdr.pluginRegistered': '已注册到 Herdr。',
145
+ 'herdr.pluginMissing': '尚未注册到 Herdr。',
146
+ 'herdr.register': '把本包注册为 Herdr 插件',
147
+ 'herdr.unregister': '取消注册 Herdr 插件',
148
+ 'herdr.registerDone': '已注册:{path}',
149
+ 'herdr.unregisterDone': '插件已取消注册。',
150
+ 'herdr.registerFailed': '注册失败:{message}',
151
+ 'herdr.cliMissing': 'PATH 中找不到 herdr 命令。',
152
+
153
+ 'about.title': '语言与关于',
154
+ 'about.language': '界面语言',
155
+ 'about.languageAuto': '跟随系统({detected})',
156
+ 'about.languageZh': '中文',
157
+ 'about.languageEn': 'English',
158
+ 'about.version': '版本',
159
+ 'about.configPath': '配置文件',
160
+ 'about.statePath': '状态目录',
161
+ 'about.relayPackage': 'Relay 包',
162
+ 'about.docs': '自建 relay 指南:docs/self-hosted-relay.zh-CN.md',
163
+
164
+ 'wizard.title': '首次设置',
165
+ 'wizard.step': '第 {current} / {total} 步',
166
+ 'wizard.languageTitle': '选择语言',
167
+ 'wizard.accessTitle': '你打算怎么访问这台机器?',
168
+ 'wizard.accessHint': '之后可以在 Relay 页面随时修改。',
169
+ 'wizard.addressTitle': '手机应该使用哪个地址?',
170
+ 'wizard.relayTitle': '你的 relay 服务器',
171
+ 'wizard.relayUrlLabel': 'Relay 地址(wss://…)',
172
+ 'wizard.relayHint': 'relay 需要已经在运行。搭建方法见 docs/self-hosted-relay.zh-CN.md。',
173
+ 'wizard.passwordTitle': 'Relay 密码',
174
+ 'wizard.passwordHint': 'relay 启动时设置的 RELAY_PASSWORD。relay 没设密码就留空。',
175
+ 'wizard.finishTitle': '完成',
176
+ 'wizard.finishHint': '配置将保存到 {path}。',
177
+ 'wizard.startNow': '立即启动服务',
178
+ 'wizard.installKeepalive': '让服务在后台保持运行',
179
+ 'wizard.finish': '保存并继续',
180
+
181
+ 'field.mode': '访问方式',
182
+ 'field.port': 'Relay 端口',
183
+ 'field.lanHost': '浏览器地址',
184
+ 'field.remoteUrl': 'Relay 地址',
185
+ 'field.publicUrl': '浏览器地址(覆盖)',
186
+ 'field.socketPath': 'Herdr 套接字路径',
187
+ 'field.herdrArgs': 'Herdr 参数',
188
+ 'field.language': '语言',
189
+ 'field.keepalive': '保活管理方式',
190
+
191
+ 'placeholder.autoDiscovered': '自动探测',
192
+ 'placeholder.none': '无',
193
+
194
+ 'error.invalidMode': '未知的访问方式。',
195
+ 'error.invalidPort': '端口必须是 1 到 65535 之间的数字。',
196
+ 'error.invalidLanHost': '浏览器地址必须是可访问的局域网或 Tailscale 地址,不能填写回环地址或 0.0.0.0。',
197
+ 'error.invalidRelayUrl': 'Relay 地址需以 wss://、ws://、https:// 或 http:// 开头。',
198
+ 'error.invalidPublicUrl': '浏览器地址需以 https:// 或 http:// 开头,不能使用 0.0.0.0 或 ::。',
199
+ 'error.invalidLanguage': '未知的语言。',
200
+ 'error.invalidKeepalive': '未知的保活管理方式。',
201
+ 'error.unknownField': '未知的设置项。',
202
+ 'error.remoteUrlRequired': '自建 relay 模式必须填写 Relay 地址。',
203
+ 'error.saveFailed': '保存配置失败:{message}',
204
+
205
+ 'hint.navigate': '↑↓ 移动',
206
+ 'hint.select': '↵ 选择',
207
+ 'hint.edit': '↵ 编辑',
208
+ 'hint.tabs': '← → 切换页面',
209
+ 'hint.back': 'esc 返回',
210
+ 'hint.quit': 'q 退出',
211
+ 'hint.mouseOn': 'm 关闭鼠标',
212
+ 'hint.mouseOff': 'm 开启鼠标',
213
+ 'hint.mouseUnsupported': '不支持鼠标',
214
+ 'hint.restartRequired': '需要重启服务才能生效。',
215
+ 'hint.unsavedChanges': '有未保存的修改 — 按 s 保存。',
216
+ 'hint.save': 's 保存',
217
+ 'hint.editing': '↵ 确认 · esc 取消',
218
+ };
@@ -0,0 +1,404 @@
1
+ 'use strict';
2
+
3
+ // Keep-alive integration.
4
+ //
5
+ // "Start the services" and "keep the services running" are different problems:
6
+ // a detached spawn dies with the first crash and never comes back. This module
7
+ // hands supervision to the platform's service manager where one exists
8
+ // (systemd --user on Linux, launchd on macOS) and falls back to a detached copy
9
+ // of our own supervisor where neither does.
10
+
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+ const { spawn, spawnSync } = require('node:child_process');
15
+ const { PACKAGE_ROOT, loadConfig, stateDir } = require('./config');
16
+ const { ensureDir, readJson, writeJsonAtomic } = require('./state');
17
+ const { logPath, pidAlive } = require('./service');
18
+
19
+ const SYSTEMD_UNIT_NAME = 'herdr-remote.service';
20
+ const LAUNCHD_LABEL = 'dev.herdr.remote';
21
+ const FALLBACK_PID_FILE = 'supervisor.pid';
22
+
23
+ function cliEntryPoint() {
24
+ return path.join(PACKAGE_ROOT, 'bin', 'herdr-remote.js');
25
+ }
26
+
27
+ function systemdUnitPath() {
28
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
29
+ return path.join(configHome, 'systemd', 'user', SYSTEMD_UNIT_NAME);
30
+ }
31
+
32
+ function launchdPlistPath() {
33
+ return path.join(os.homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
34
+ }
35
+
36
+ function fallbackPidPath() {
37
+ return path.join(stateDir(), FALLBACK_PID_FILE);
38
+ }
39
+
40
+ /**
41
+ * Look for an executable on PATH without going through a shell: `spawnSync`
42
+ * with `shell: true` concatenates rather than escapes its arguments, and Node
43
+ * now warns about it on every call.
44
+ */
45
+ function commandExists(command) {
46
+ const searchPath = process.env.PATH || '';
47
+ return searchPath.split(path.delimiter).filter(Boolean).some((directory) => {
48
+ try {
49
+ fs.accessSync(path.join(directory, command), fs.constants.X_OK);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ });
55
+ }
56
+
57
+ /** Which manager to use given the platform, the user's preference and reality. */
58
+ function detectManager(preference = 'auto') {
59
+ if (preference && preference !== 'auto') return preference;
60
+ if (process.platform === 'linux') {
61
+ // `systemctl --user` needs a user bus; containers and bare TTY logins often
62
+ // have systemd installed but no session bus, where it would fail at runtime.
63
+ if (commandExists('systemctl') && (process.env.DBUS_SESSION_BUS_ADDRESS || process.env.XDG_RUNTIME_DIR)) {
64
+ return 'systemd';
65
+ }
66
+ return 'supervisor';
67
+ }
68
+ if (process.platform === 'darwin') {
69
+ return commandExists('launchctl') ? 'launchd' : 'supervisor';
70
+ }
71
+ return 'supervisor';
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Unit file rendering (pure, so it can be asserted in tests)
76
+ // ---------------------------------------------------------------------------
77
+
78
+ function renderSystemdUnit({ nodePath = process.execPath, entryPoint = cliEntryPoint(), environment = {} } = {}) {
79
+ const environmentLines = Object.entries(environment)
80
+ .map(([key, value]) => `Environment=${key}=${value}`)
81
+ .join('\n');
82
+ return `[Unit]
83
+ Description=Herdr Remote (relay and host connector)
84
+ Documentation=https://github.com/herdr/herdr-remote
85
+ After=default.target
86
+
87
+ [Service]
88
+ Type=simple
89
+ ExecStart=${nodePath} ${entryPoint} run
90
+ Restart=always
91
+ RestartSec=3
92
+ # Give the relay and host connector time to close sessions cleanly.
93
+ TimeoutStopSec=15
94
+ ${environmentLines}
95
+
96
+ [Install]
97
+ WantedBy=default.target
98
+ `;
99
+ }
100
+
101
+ function escapeXml(value) {
102
+ return String(value)
103
+ .replace(/&/g, '&')
104
+ .replace(/</g, '&lt;')
105
+ .replace(/>/g, '&gt;')
106
+ .replace(/"/g, '&quot;')
107
+ .replace(/'/g, '&apos;');
108
+ }
109
+
110
+ function renderLaunchdPlist({
111
+ nodePath = process.execPath,
112
+ entryPoint = cliEntryPoint(),
113
+ label = LAUNCHD_LABEL,
114
+ outLog = logPath('supervisor'),
115
+ errLog = logPath('supervisor'),
116
+ environment = {},
117
+ } = {}) {
118
+ const environmentEntries = Object.entries(environment)
119
+ .map(([key, value]) => ` <key>${escapeXml(key)}</key>\n <string>${escapeXml(value)}</string>`)
120
+ .join('\n');
121
+ const environmentBlock = environmentEntries
122
+ ? ` <key>EnvironmentVariables</key>\n <dict>\n${environmentEntries}\n </dict>\n`
123
+ : '';
124
+ return `<?xml version="1.0" encoding="UTF-8"?>
125
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
126
+ <plist version="1.0">
127
+ <dict>
128
+ <key>Label</key>
129
+ <string>${escapeXml(label)}</string>
130
+ <key>ProgramArguments</key>
131
+ <array>
132
+ <string>${escapeXml(nodePath)}</string>
133
+ <string>${escapeXml(entryPoint)}</string>
134
+ <string>run</string>
135
+ </array>
136
+ <key>RunAtLoad</key>
137
+ <true/>
138
+ <key>KeepAlive</key>
139
+ <true/>
140
+ <key>ProcessType</key>
141
+ <string>Background</string>
142
+ ${environmentBlock} <key>StandardOutPath</key>
143
+ <string>${escapeXml(outLog)}</string>
144
+ <key>StandardErrorPath</key>
145
+ <string>${escapeXml(errLog)}</string>
146
+ </dict>
147
+ </plist>
148
+ `;
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // systemd
153
+ // ---------------------------------------------------------------------------
154
+
155
+ function systemctl(args, { capture = true } = {}) {
156
+ return spawnSync('systemctl', ['--user', ...args], {
157
+ encoding: 'utf8',
158
+ stdio: capture ? 'pipe' : 'inherit',
159
+ });
160
+ }
161
+
162
+ function systemdStatus() {
163
+ const installed = fs.existsSync(systemdUnitPath());
164
+ if (!installed) return { manager: 'systemd', installed: false, active: false, enabled: false };
165
+ const active = systemctl(['is-active', SYSTEMD_UNIT_NAME]);
166
+ const enabled = systemctl(['is-enabled', SYSTEMD_UNIT_NAME]);
167
+ const lingering = spawnSync('loginctl', ['show-user', os.userInfo().username, '--property=Linger'], { encoding: 'utf8' });
168
+ return {
169
+ manager: 'systemd',
170
+ installed: true,
171
+ active: String(active.stdout || '').trim() === 'active',
172
+ enabled: String(enabled.stdout || '').trim().startsWith('enabled'),
173
+ linger: String(lingering.stdout || '').includes('Linger=yes'),
174
+ unitPath: systemdUnitPath(),
175
+ state: String(active.stdout || active.stderr || '').trim(),
176
+ };
177
+ }
178
+
179
+ function systemdInstall() {
180
+ const unitPath = systemdUnitPath();
181
+ ensureDir(path.dirname(unitPath));
182
+ fs.writeFileSync(unitPath, renderSystemdUnit(), { mode: 0o644 });
183
+ const reload = systemctl(['daemon-reload']);
184
+ if (reload.status !== 0) {
185
+ throw new Error(`systemctl --user daemon-reload failed: ${String(reload.stderr || '').trim()}`);
186
+ }
187
+ const enable = systemctl(['enable', '--now', SYSTEMD_UNIT_NAME]);
188
+ if (enable.status !== 0) {
189
+ throw new Error(`systemctl --user enable --now failed: ${String(enable.stderr || '').trim()}`);
190
+ }
191
+ return { ok: true, unitPath, hint: `loginctl enable-linger ${os.userInfo().username}` };
192
+ }
193
+
194
+ function systemdUninstall() {
195
+ const unitPath = systemdUnitPath();
196
+ systemctl(['disable', '--now', SYSTEMD_UNIT_NAME]);
197
+ if (fs.existsSync(unitPath)) fs.rmSync(unitPath, { force: true });
198
+ systemctl(['daemon-reload']);
199
+ return { ok: true, unitPath };
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // launchd
204
+ // ---------------------------------------------------------------------------
205
+
206
+ function launchdDomainTarget() {
207
+ return `gui/${process.getuid ? process.getuid() : ''}`;
208
+ }
209
+
210
+ function launchdStatus() {
211
+ const plistPath = launchdPlistPath();
212
+ const installed = fs.existsSync(plistPath);
213
+ if (!installed) return { manager: 'launchd', installed: false, active: false, enabled: false };
214
+ const result = spawnSync('launchctl', ['print', `${launchdDomainTarget()}/${LAUNCHD_LABEL}`], { encoding: 'utf8' });
215
+ const output = String(result.stdout || '');
216
+ return {
217
+ manager: 'launchd',
218
+ installed: true,
219
+ active: result.status === 0 && /state = running/.test(output),
220
+ enabled: result.status === 0,
221
+ unitPath: plistPath,
222
+ state: result.status === 0 ? 'loaded' : 'not loaded',
223
+ };
224
+ }
225
+
226
+ function launchdInstall() {
227
+ const plistPath = launchdPlistPath();
228
+ ensureDir(path.dirname(plistPath));
229
+ ensureDir(stateDir());
230
+ fs.writeFileSync(plistPath, renderLaunchdPlist(), { mode: 0o644 });
231
+ // bootout first so a re-install picks up the rewritten plist.
232
+ spawnSync('launchctl', ['bootout', `${launchdDomainTarget()}/${LAUNCHD_LABEL}`], { stdio: 'ignore' });
233
+ const result = spawnSync('launchctl', ['bootstrap', launchdDomainTarget(), plistPath], { encoding: 'utf8' });
234
+ if (result.status !== 0) {
235
+ throw new Error(`launchctl bootstrap failed: ${String(result.stderr || '').trim()}`);
236
+ }
237
+ spawnSync('launchctl', ['enable', `${launchdDomainTarget()}/${LAUNCHD_LABEL}`], { stdio: 'ignore' });
238
+ return { ok: true, unitPath: plistPath };
239
+ }
240
+
241
+ function launchdUninstall() {
242
+ const plistPath = launchdPlistPath();
243
+ spawnSync('launchctl', ['bootout', `${launchdDomainTarget()}/${LAUNCHD_LABEL}`], { stdio: 'ignore' });
244
+ if (fs.existsSync(plistPath)) fs.rmSync(plistPath, { force: true });
245
+ return { ok: true, unitPath: plistPath };
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Fallback supervisor daemon
250
+ // ---------------------------------------------------------------------------
251
+
252
+ function readFallbackPid() {
253
+ const record = readJson(fallbackPidPath(), {});
254
+ return Number.isInteger(record.pid) ? record.pid : null;
255
+ }
256
+
257
+ function fallbackStatus() {
258
+ const pid = readFallbackPid();
259
+ const alive = pidAlive(pid);
260
+ return {
261
+ manager: 'supervisor',
262
+ installed: alive,
263
+ active: alive,
264
+ enabled: false,
265
+ pid: alive ? pid : null,
266
+ unitPath: fallbackPidPath(),
267
+ state: alive ? 'running' : 'stopped',
268
+ };
269
+ }
270
+
271
+ function fallbackInstall() {
272
+ if (pidAlive(readFallbackPid())) return { ok: true, alreadyRunning: true, pid: readFallbackPid() };
273
+ ensureDir(stateDir());
274
+ const logFd = fs.openSync(logPath('supervisor'), 'a');
275
+ try {
276
+ const child = spawn(process.execPath, [cliEntryPoint(), 'run', '--daemon'], {
277
+ cwd: PACKAGE_ROOT,
278
+ env: { ...process.env, HERDR_REMOTE_SERVICE: '1' },
279
+ detached: true,
280
+ stdio: ['ignore', logFd, logFd],
281
+ });
282
+ child.unref();
283
+ writeJsonAtomic(fallbackPidPath(), { pid: child.pid, startedAt: new Date().toISOString() });
284
+ return { ok: true, pid: child.pid, note: 'this fallback does not survive a reboot' };
285
+ } finally {
286
+ fs.closeSync(logFd);
287
+ }
288
+ }
289
+
290
+ function fallbackUninstall() {
291
+ const pid = readFallbackPid();
292
+ if (pidAlive(pid)) {
293
+ try { process.kill(pid, 'SIGTERM'); } catch {}
294
+ }
295
+ try { fs.rmSync(fallbackPidPath(), { force: true }); } catch {}
296
+ return { ok: true, stoppedPid: pid };
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Public surface
301
+ // ---------------------------------------------------------------------------
302
+
303
+ function resolveManager(config = loadConfig()) {
304
+ return detectManager(config.keepalive?.manager || 'auto');
305
+ }
306
+
307
+ function status(config = loadConfig()) {
308
+ const manager = resolveManager(config);
309
+ if (manager === 'none') return { manager: 'none', installed: false, active: false, enabled: false };
310
+ if (manager === 'systemd') return systemdStatus();
311
+ if (manager === 'launchd') return launchdStatus();
312
+ return fallbackStatus();
313
+ }
314
+
315
+ function install(config = loadConfig()) {
316
+ const manager = resolveManager(config);
317
+ if (manager === 'systemd') return { manager, ...systemdInstall() };
318
+ if (manager === 'launchd') return { manager, ...launchdInstall() };
319
+ if (manager === 'none') throw new Error('keep-alive is disabled in the configuration');
320
+ return { manager: 'supervisor', ...fallbackInstall() };
321
+ }
322
+
323
+ function uninstall(config = loadConfig()) {
324
+ const manager = resolveManager(config);
325
+ if (manager === 'systemd') return { manager, ...systemdUninstall() };
326
+ if (manager === 'launchd') return { manager, ...launchdUninstall() };
327
+ return { manager: 'supervisor', ...fallbackUninstall() };
328
+ }
329
+
330
+ /** Restart whatever manages the services, so config edits take effect. */
331
+ function restart(config = loadConfig()) {
332
+ const manager = resolveManager(config);
333
+ if (manager === 'systemd') {
334
+ const result = systemctl(['restart', SYSTEMD_UNIT_NAME]);
335
+ if (result.status !== 0) throw new Error(String(result.stderr || '').trim() || 'systemctl restart failed');
336
+ return { manager, ok: true };
337
+ }
338
+ if (manager === 'launchd') {
339
+ spawnSync('launchctl', ['kickstart', '-k', `${launchdDomainTarget()}/${LAUNCHD_LABEL}`], { stdio: 'ignore' });
340
+ return { manager, ok: true };
341
+ }
342
+ fallbackUninstall();
343
+ return { manager: 'supervisor', ...fallbackInstall() };
344
+ }
345
+
346
+ /**
347
+ * Stop the managed services. Killing the child pids directly would only make
348
+ * the service manager start them again, so an installed manager is asked to
349
+ * stop instead.
350
+ */
351
+ function stopManaged(config = loadConfig()) {
352
+ const current = status(config);
353
+ if (!current.installed && !current.active) return { managed: false };
354
+ if (current.manager === 'systemd') {
355
+ systemctl(['stop', SYSTEMD_UNIT_NAME]);
356
+ return { managed: true, manager: 'systemd' };
357
+ }
358
+ if (current.manager === 'launchd') {
359
+ spawnSync('launchctl', ['bootout', `${launchdDomainTarget()}/${LAUNCHD_LABEL}`], { stdio: 'ignore' });
360
+ return { managed: true, manager: 'launchd' };
361
+ }
362
+ if (current.manager === 'supervisor' && current.active) {
363
+ fallbackUninstall();
364
+ return { managed: true, manager: 'supervisor' };
365
+ }
366
+ return { managed: false };
367
+ }
368
+
369
+ function enableLinger() {
370
+ const username = os.userInfo().username;
371
+ const result = spawnSync('loginctl', ['enable-linger', username], { encoding: 'utf8' });
372
+ if (result.status !== 0) {
373
+ throw new Error(String(result.stderr || '').trim() || 'loginctl enable-linger failed');
374
+ }
375
+ return { ok: true, username };
376
+ }
377
+
378
+ function logsHint(config = loadConfig()) {
379
+ const manager = resolveManager(config);
380
+ if (manager === 'systemd') return `journalctl --user -u ${SYSTEMD_UNIT_NAME} -f`;
381
+ if (manager === 'launchd') return `tail -f ${logPath('supervisor')}`;
382
+ return `tail -f ${logPath('supervisor')}`;
383
+ }
384
+
385
+ module.exports = {
386
+ SYSTEMD_UNIT_NAME,
387
+ LAUNCHD_LABEL,
388
+ cliEntryPoint,
389
+ systemdUnitPath,
390
+ launchdPlistPath,
391
+ fallbackPidPath,
392
+ detectManager,
393
+ resolveManager,
394
+ renderSystemdUnit,
395
+ renderLaunchdPlist,
396
+ escapeXml,
397
+ status,
398
+ install,
399
+ uninstall,
400
+ restart,
401
+ stopManaged,
402
+ enableLinger,
403
+ logsHint,
404
+ };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ // Start/stop/restart that respect the keep-alive manager.
4
+ //
5
+ // When systemd or launchd supervises the services, killing the processes just
6
+ // makes the manager start them again — and starting them by hand produces a
7
+ // second, unmanaged copy fighting for the port. Every entry point goes through
8
+ // here so the managed and unmanaged paths behave the same from the outside.
9
+
10
+ const { loadConfig } = require('./config');
11
+ const keepalive = require('./keepalive');
12
+ const { restartServices, startServices, statusServices, stopServices } = require('./service');
13
+
14
+ function managerInUse(config) {
15
+ const status = keepalive.status(config);
16
+ return status.installed || status.active ? status : null;
17
+ }
18
+
19
+ function startAll(config = loadConfig()) {
20
+ const managed = managerInUse(config);
21
+ if (managed) {
22
+ keepalive.restart(config);
23
+ return { ok: true, managed: true, manager: managed.manager };
24
+ }
25
+ return { ...startServices(), managed: false };
26
+ }
27
+
28
+ function stopAll(config = loadConfig()) {
29
+ const result = keepalive.stopManaged(config);
30
+ if (result.managed) {
31
+ // The manager stops the supervisor, which stops its children; clear the
32
+ // recorded pids so status does not report ghosts.
33
+ stopServices();
34
+ return { ok: true, managed: true, manager: result.manager };
35
+ }
36
+ return { ...stopServices(), managed: false };
37
+ }
38
+
39
+ function restartAll(config = loadConfig()) {
40
+ const managed = managerInUse(config);
41
+ if (managed) {
42
+ keepalive.restart(config);
43
+ return { ok: true, managed: true, manager: managed.manager };
44
+ }
45
+ return { ...restartServices(), managed: false };
46
+ }
47
+
48
+ /** Service status plus the keep-alive picture, which is what the TUI shows. */
49
+ async function fullStatus(config = loadConfig()) {
50
+ const [services, keepaliveStatus] = [await statusServices(), keepalive.status(config)];
51
+ return {
52
+ ...services,
53
+ keepalive: keepaliveStatus,
54
+ logsHint: keepalive.logsHint(config),
55
+ };
56
+ }
57
+
58
+ module.exports = { startAll, stopAll, restartAll, fullStatus, managerInUse };