herdr-remote 0.2.20 → 0.2.22

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.
Files changed (46) hide show
  1. package/README.md +12 -2
  2. package/README.zh-CN.md +11 -1
  3. package/dist/cli.js +23 -4
  4. package/dist/config.js +8 -1
  5. package/dist/herdr-api.js +5 -3
  6. package/dist/herdr-command.js +32 -15
  7. package/dist/herdr-plugin.js +5 -1
  8. package/dist/herdr-server.js +3 -2
  9. package/dist/i18n/en.js +20 -0
  10. package/dist/i18n/zh.js +20 -0
  11. package/dist/keepalive/detached.js +13 -3
  12. package/dist/keepalive/index.js +16 -8
  13. package/dist/keepalive/launchd.js +4 -4
  14. package/dist/keepalive/systemd.js +8 -4
  15. package/dist/keepalive/windows-service-setup.js +129 -0
  16. package/dist/keepalive/windows-service.js +272 -0
  17. package/dist/lifecycle.js +15 -1
  18. package/dist/net-interfaces.js +15 -6
  19. package/dist/paths.js +16 -4
  20. package/dist/pty-session.js +10 -2
  21. package/dist/relay-updater.js +81 -0
  22. package/dist/service.js +2 -0
  23. package/dist/socket-discovery.js +15 -3
  24. package/dist/supervisor.js +1 -0
  25. package/dist/terminal-font/deps.js +11 -1
  26. package/dist/terminal-font/files.js +13 -10
  27. package/dist/terminal-font/readers.js +60 -3
  28. package/dist/terminal-font/terminals.js +3 -0
  29. package/dist/terminal-palette.js +78 -18
  30. package/dist/tui/App.js +34 -8
  31. package/dist/tui/api.js +6 -4
  32. package/dist/tui/screens/About.js +30 -15
  33. package/dist/tui/screens/Herdr.js +5 -4
  34. package/dist/tui/screens/Keepalive.js +25 -14
  35. package/dist/tui/screens/Pair.js +1 -1
  36. package/dist/tui/screens/Relay.js +9 -8
  37. package/dist/tui/screens/Services.js +4 -4
  38. package/dist/tui/screens/Wizard.js +11 -5
  39. package/dist/tui/screens/useRelayUpdate.js +122 -0
  40. package/dist/updater/npm.js +62 -0
  41. package/dist/updater.js +8 -37
  42. package/herdr-plugin.toml +2 -2
  43. package/package.json +2 -1
  44. package/vendor/winsw/LICENSE +21 -0
  45. package/vendor/winsw/README.md +9 -0
  46. package/vendor/winsw/WinSW.NET461.exe +0 -0
package/README.md CHANGED
@@ -13,6 +13,11 @@ herdr-remote
13
13
 
14
14
  Running `herdr-remote` without arguments launches the setup wizard and configuration TUI.
15
15
 
16
+ Native Windows 10/11 is supported with Node.js 22+; install Herdr from PowerShell with
17
+ `powershell -ExecutionPolicy Bypass -c "irm https://herdr.dev/install.ps1 | iex"`.
18
+ Herdr's Windows plugin support is in preview; on the first LAN launch, allow `node.exe`
19
+ through Windows Defender Firewall on private networks.
20
+
16
21
  ## Connection Modes
17
22
 
18
23
  - **This machine only** *(default)*: Local workstation browser (127.0.0.1).
@@ -43,15 +48,20 @@ herdr-remote plugin link
43
48
  ## Configuration
44
49
 
45
50
  - Settings: `~/.config/herdr-remote/config.json`
51
+ - Windows settings: `%APPDATA%\herdr-remote\config.json`
46
52
  - Runtime state: `~/.local/state/herdr-remote/runtime.json` (mode `0600`)
53
+ - Windows runtime state: `%LOCALAPPDATA%\herdr-remote\runtime.json`
47
54
 
48
55
  ## Locating Herdr
49
56
 
50
57
  The `herdr` binary is looked up in `HERDR_BIN_PATH`, then `PATH`, then the usual
51
58
  install directories (`~/.local/bin`, `~/.cargo/bin`, `~/bin`, `/opt/homebrew/bin`,
52
59
  `/usr/local/bin`, …). `herdr-remote keepalive install` writes the binary it found
53
- and the current `PATH` into the systemd unit or launchd agent, because a service
54
- manager does not inherit your shell's environment.
60
+ and the current `PATH` into the systemd unit, launchd agent, or Windows service
61
+ (`windows-service`), because a service manager does not inherit your shell's environment.
62
+
63
+ On Windows, only `herdr.exe` is searched for, including `%LOCALAPPDATA%\Programs\Herdr\bin`,
64
+ `~\.cargo\bin`, and `~\scoop\shims`.
55
65
 
56
66
  `herdr-remote status --json` reports `host.herdrCommand` and `host.herdrCommandFound`.
57
67
  If it was not found, set `HERDR_BIN_PATH` to the full path and reinstall the
package/README.zh-CN.md CHANGED
@@ -13,6 +13,11 @@ herdr-remote
13
13
 
14
14
  直接运行 `herdr-remote` 即可启动向导并进入配置 TUI。
15
15
 
16
+ 已支持 Windows 10/11 原生运行(需 Node.js 22+),请在 PowerShell 中执行
17
+ `powershell -ExecutionPolicy Bypass -c "irm https://herdr.dev/install.ps1 | iex"` 安装 Herdr。
18
+ Herdr 的 Windows 插件功能仍处于 preview 阶段;首次以局域网模式启动时,请在 Windows
19
+ Defender 防火墙提示中允许 `node.exe` 通过专用网络通信。
20
+
16
21
  ## 访问模式
17
22
 
18
23
  - **仅本机** *(默认)*:仅本机浏览器可访问 (127.0.0.1)。
@@ -43,14 +48,19 @@ herdr-remote plugin link
43
48
  ## 配置路径
44
49
 
45
50
  - 配置文件:`~/.config/herdr-remote/config.json`
51
+ - Windows 配置文件:`%APPDATA%\herdr-remote\config.json`
46
52
  - 运行状态:`~/.local/state/herdr-remote/runtime.json` (权限 `0600`)
53
+ - Windows 运行状态:`%LOCALAPPDATA%\herdr-remote\runtime.json`
47
54
 
48
55
  ## herdr 命令的查找方式
49
56
 
50
57
  依次查找 `HERDR_BIN_PATH`、`PATH`,以及常见安装目录(`~/.local/bin`、`~/.cargo/bin`、
51
58
  `~/bin`、`/opt/homebrew/bin`、`/usr/local/bin` 等)。服务管理器不会继承 shell 环境,
52
59
  因此 `herdr-remote keepalive install` 会把找到的可执行文件路径和当前 `PATH` 写入
53
- systemd unit 或 launchd plist。
60
+ systemd unit、launchd plist 或 Windows 服务(`windows-service`)。
61
+
62
+ Windows 上只查找 `herdr.exe`,还会搜索 `%LOCALAPPDATA%\Programs\Herdr\bin`、
63
+ `~\.cargo\bin` 和 `~\scoop\shims`。
54
64
 
55
65
  `herdr-remote status --json` 会输出 `host.herdrCommand` 与 `host.herdrCommandFound`。
56
66
  若显示未找到,请将 `HERDR_BIN_PATH` 设为完整路径后重新安装保活服务。
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // the subcommands exist for scripting and for the actions declared in
5
5
  // herdr-plugin.toml, which Herdr runs without a terminal attached.
6
6
  import fs from 'node:fs';
7
+ import os from 'node:os';
7
8
  import path from 'node:path';
8
9
  import { LANGUAGES, loadConfig, migrateLegacyConfig } from './config.js';
9
10
  import { PACKAGE_ROOT, configPath, stateDir } from './paths.js';
@@ -49,6 +50,10 @@ function parseArgs(argv) {
49
50
  flags.daemon = true;
50
51
  continue;
51
52
  }
53
+ if (arg === '--elevated') {
54
+ flags.elevated = true;
55
+ continue;
56
+ }
52
57
  if (arg === '--help' || arg === '-h') {
53
58
  flags.help = true;
54
59
  continue;
@@ -108,6 +113,16 @@ async function runTui(options) {
108
113
  }
109
114
  async function main(argv = process.argv.slice(2)) {
110
115
  const { positional, flags } = parseArgs(argv);
116
+ if (process.platform === 'win32') {
117
+ const relative = path.relative(PACKAGE_ROOT, process.cwd());
118
+ const insidePackage = relative === '' ||
119
+ (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
120
+ if (insidePackage) {
121
+ // Herdr starts plugin panels in PACKAGE_ROOT; Windows locks the cwd,
122
+ // preventing npm from replacing that directory.
123
+ process.chdir(os.homedir());
124
+ }
125
+ }
111
126
  const command = positional[0] || null;
112
127
  // Answered before anything reads the config or queries the terminal.
113
128
  if (flags.help) {
@@ -128,7 +143,7 @@ async function main(argv = process.argv.slice(2)) {
128
143
  // TUI takes the screen a few lines below, and the services this command
129
144
  // starts usually run detached with nothing to ask. Everything downstream
130
145
  // inherits the answer through the environment.
131
- (await import('./terminal-palette.js')).captureTerminalPalette();
146
+ await (await import('./terminal-palette.js')).captureTerminalPalette();
132
147
  // The font, likewise, is read while this process still knows which terminal
133
148
  // it runs in: its variables and its parent process are gone once a service
134
149
  // manager or Herdr's detached server starts the connector.
@@ -210,10 +225,14 @@ async function main(argv = process.argv.slice(2)) {
210
225
  case 'keepalive': {
211
226
  const keepalive = await import('./keepalive/index.js');
212
227
  const action = positional[1] || 'status';
213
- if (action === 'install')
214
- printJson(keepalive.install(config));
228
+ if (action === 'install' && flags.elevated) {
229
+ const { completeServiceInstall } = await import('./keepalive/windows-service-setup.js');
230
+ process.exitCode = await completeServiceInstall(t);
231
+ }
232
+ else if (action === 'install')
233
+ printJson(await keepalive.install(config));
215
234
  else if (action === 'uninstall')
216
- printJson(keepalive.uninstall(config));
235
+ printJson(await keepalive.uninstall(config));
217
236
  else if (action === 'restart')
218
237
  printJson(keepalive.restart(config));
219
238
  else if (action === 'status')
package/dist/config.js CHANGED
@@ -20,7 +20,14 @@ const ACCESS_MODES = ['local', 'lan', 'remote'];
20
20
  // workstation stays reachable only through its own host token.
21
21
  const OFFICIAL_RELAY_URL = 'wss://herdr-remote.564616.xyz';
22
22
  const LANGUAGES = ['auto', 'zh', 'en'];
23
- const KEEPALIVE_MANAGERS = ['auto', 'systemd', 'launchd', 'supervisor', 'none'];
23
+ const KEEPALIVE_MANAGERS = [
24
+ 'auto',
25
+ 'systemd',
26
+ 'launchd',
27
+ 'supervisor',
28
+ 'windows-service',
29
+ 'none',
30
+ ];
24
31
  function isOneOf(list, value) {
25
32
  return list.includes(value);
26
33
  }
package/dist/herdr-api.js CHANGED
@@ -7,7 +7,8 @@
7
7
  * phone can answer "does anything need me?" without navigating a TUI through a
8
8
  * 40-column viewport.
9
9
  *
10
- * The wire format is newline-delimited JSON over the Unix socket:
10
+ * The wire format is newline-delimited JSON over the Unix socket or, on
11
+ * Windows, the named pipe it names:
11
12
  *
12
13
  * request {"id":"…","method":"session.snapshot","params":{}}
13
14
  * response {"id":"…","result":{…}} or {"id":"…","error":{"code","message"}}
@@ -28,6 +29,7 @@
28
29
  * the PTYs keep streaming while this quietly fails and is asked again later.
29
30
  */
30
31
  import net from 'node:net';
32
+ import { herdrEndpoint } from './socket-discovery.js';
31
33
  /** How long one question waits, connection included. */
32
34
  const REQUEST_TIMEOUT_MS = 10_000;
33
35
  /** An answer this long is not an answer. Guards a socket that never newlines. */
@@ -69,7 +71,7 @@ function requestHerdr(socketPath, method, params = {}, options = {}) {
69
71
  if (typeof timer.unref === 'function')
70
72
  timer.unref();
71
73
  try {
72
- socket = connect(socketPath);
74
+ socket = connect(herdrEndpoint(socketPath));
73
75
  }
74
76
  catch (error) {
75
77
  finish(error);
@@ -145,7 +147,7 @@ function subscribeHerdr(socketPath, subscriptions, onEvent, options = {}) {
145
147
  return;
146
148
  let current;
147
149
  try {
148
- current = connect(socketPath);
150
+ current = connect(herdrEndpoint(socketPath));
149
151
  }
150
152
  catch {
151
153
  scheduleReconnect();
@@ -48,11 +48,11 @@ function expandHome(directory, home) {
48
48
  return path.join(home, directory.slice(2));
49
49
  return directory;
50
50
  }
51
- /** Windows keeps the executable bit in the extension instead of the mode. */
52
- function candidateNames(base = COMMAND_NAME) {
53
- return process.platform === 'win32'
54
- ? [base, `${base}.exe`, `${base}.cmd`, `${base}.bat`]
55
- : [base];
51
+ /** Windows only publishes Herdr as an executable; shell scripts need a shell. */
52
+ function candidateNames(base = COMMAND_NAME, platform = process.platform) {
53
+ if (platform !== 'win32')
54
+ return [base];
55
+ return [/\.exe$/i.test(base) ? base : `${base}.exe`];
56
56
  }
57
57
  function isExecutableFile(candidate) {
58
58
  try {
@@ -83,7 +83,14 @@ function findOnSearchPath(searchPath, names = candidateNames()) {
83
83
  }
84
84
  return null;
85
85
  }
86
- function fallbackDirectories(home = os.homedir()) {
86
+ function fallbackDirectories(home = os.homedir(), platform = process.platform, env = process.env) {
87
+ if (platform === 'win32') {
88
+ return [
89
+ path.join(env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'Programs', 'Herdr', 'bin'),
90
+ path.join(home, '.cargo', 'bin'),
91
+ path.join(home, 'scoop', 'shims'),
92
+ ];
93
+ }
87
94
  return FALLBACK_DIRECTORIES.map((directory) => expandHome(directory, home));
88
95
  }
89
96
  function looksLikePath(value) {
@@ -98,7 +105,7 @@ function looksLikePath(value) {
98
105
  * treated as absent rather than fatal: a stale override left behind by a
99
106
  * moved install is precisely the case we are trying to survive.
100
107
  */
101
- function resolveOverride(value, searchPath) {
108
+ function resolveOverride(value, searchPath, platform) {
102
109
  const trimmed = String(value || '').trim();
103
110
  if (!trimmed)
104
111
  return null;
@@ -106,9 +113,14 @@ function resolveOverride(value, searchPath) {
106
113
  const absolute = path.resolve(trimmed);
107
114
  if (isExecutableFile(absolute))
108
115
  return absolute;
109
- return findInDirectory(absolute);
116
+ if (platform === 'win32' && path.extname(absolute) === '') {
117
+ const executable = `${absolute}.exe`;
118
+ if (isExecutableFile(executable))
119
+ return executable;
120
+ }
121
+ return findInDirectory(absolute, candidateNames(COMMAND_NAME, platform));
110
122
  }
111
- return findOnSearchPath(searchPath, candidateNames(trimmed));
123
+ return findOnSearchPath(searchPath, candidateNames(trimmed, platform));
112
124
  }
113
125
  /**
114
126
  * Everything known about where Herdr is, so callers can report it and not only
@@ -120,15 +132,16 @@ function resolveOverride(value, searchPath) {
120
132
  * bare name: `PATH` may be right in a way we cannot see from here, and letting
121
133
  * the spawn proceed keeps the old behaviour for anyone relying on it.
122
134
  */
123
- function findHerdrCommand({ env = process.env, home = os.homedir(), directories = fallbackDirectories(home), } = {}) {
124
- const override = resolveOverride(env.HERDR_BIN_PATH, env.PATH);
135
+ function findHerdrCommand({ env = process.env, home = os.homedir(), platform = process.platform, directories = fallbackDirectories(home, platform, env), } = {}) {
136
+ const names = candidateNames(COMMAND_NAME, platform);
137
+ const override = resolveOverride(env.HERDR_BIN_PATH, env.PATH, platform);
125
138
  if (override)
126
139
  return { command: override, source: 'env', found: true };
127
- const onPath = findOnSearchPath(env.PATH);
140
+ const onPath = findOnSearchPath(env.PATH, names);
128
141
  if (onPath)
129
142
  return { command: onPath, source: 'path', found: true };
130
143
  for (const directory of directories) {
131
- const found = findInDirectory(directory);
144
+ const found = findInDirectory(directory, names);
132
145
  if (found)
133
146
  return { command: found, source: 'fallback', found: true };
134
147
  }
@@ -185,7 +198,11 @@ function herdrVersion({ command, timeout = 10_000, ...lookup } = {}) {
185
198
  };
186
199
  let result;
187
200
  try {
188
- result = spawnSync(resolved, ['--version'], { encoding: 'utf8', timeout });
201
+ result = spawnSync(resolved, ['--version'], {
202
+ encoding: 'utf8',
203
+ timeout,
204
+ windowsHide: true,
205
+ });
189
206
  }
190
207
  catch {
191
208
  return unknown;
@@ -211,7 +228,7 @@ function herdrOutdatedMessage(version) {
211
228
  'misbehave in browser windows before that release.');
212
229
  }
213
230
  /** The one message a user needs to fix a missing install themselves. */
214
- function herdrNotFoundMessage({ env = process.env, home = os.homedir(), directories = fallbackDirectories(home), } = {}) {
231
+ function herdrNotFoundMessage({ env = process.env, home = os.homedir(), platform = process.platform, directories = fallbackDirectories(home, platform, env), } = {}) {
215
232
  const override = String(env.HERDR_BIN_PATH || '').trim();
216
233
  const parts = [`Herdr executable "${COMMAND_NAME}" was not found.`];
217
234
  if (override)
@@ -17,7 +17,11 @@ function manifestPath() {
17
17
  // manager, neither of which is guaranteed the PATH the user installed with, so
18
18
  // the command is resolved rather than named.
19
19
  function runHerdr(args, { timeout = 15_000 } = {}) {
20
- const result = spawnSync(resolveHerdrCommand(), args, { encoding: 'utf8', timeout });
20
+ const result = spawnSync(resolveHerdrCommand(), args, {
21
+ encoding: 'utf8',
22
+ timeout,
23
+ windowsHide: true,
24
+ });
21
25
  if (result.error?.code === 'ENOENT') {
22
26
  throw Object.assign(new Error('herdr command not found'), { code: 'HERDR_NOT_FOUND' });
23
27
  }
@@ -21,7 +21,7 @@ import fs from 'node:fs';
21
21
  import net from 'node:net';
22
22
  import path from 'node:path';
23
23
  import { spawn } from 'node:child_process';
24
- import { inspectSocket } from './socket-discovery.js';
24
+ import { herdrEndpoint, inspectSocket } from './socket-discovery.js';
25
25
  import { ensureDir } from 'herdr-remote-relay/state';
26
26
  /** A Unix socket either accepts at once or is dead; this only guards a hang. */
27
27
  const PROBE_TIMEOUT_MS = 1_000;
@@ -58,7 +58,7 @@ function probeHerdrServer(socketPath, { connect = net.connect, timeout = PROBE_T
58
58
  const timer = setTimeout(() => finish({ state: 'running' }), timeout);
59
59
  if (typeof timer.unref === 'function')
60
60
  timer.unref();
61
- socket = connect(socketPath);
61
+ socket = connect(herdrEndpoint(socketPath));
62
62
  socket.once('connect', () => finish({ state: 'running' }));
63
63
  socket.once('error', (error) => finish(error.code === 'ECONNREFUSED' || error.code === 'ENOENT'
64
64
  ? { state: 'stopped', stale: true }
@@ -130,6 +130,7 @@ function launchHerdrServer({ command, args = [], socketPath, cwd, logPath, spawn
130
130
  cwd,
131
131
  env: serverEnv(socketPath),
132
132
  detached: true,
133
+ windowsHide: true,
133
134
  stdio: ['ignore', out, out],
134
135
  });
135
136
  }
package/dist/i18n/en.js CHANGED
@@ -131,6 +131,14 @@ const en = {
131
131
  'keepalive.enableLinger': 'Enable start at boot',
132
132
  'keepalive.lingerDone': 'Start at boot enabled for {username}.',
133
133
  'keepalive.fallbackNote': 'No system service manager found; background process will not survive reboot.',
134
+ 'keepalive.serviceNote': 'Runs as a Windows service under your account and starts at boot, before anyone signs in. Installing or removing it asks for administrator rights.',
135
+ 'keepalive.serviceWindowHint': 'Finish the installation in the administrator window that opens.',
136
+ 'keepalive.elevationCancelled': 'Administrator rights were not granted; nothing was installed.',
137
+ 'keepalive.passwordPrompt': 'Windows password for {account} (the service signs in as you):',
138
+ 'keepalive.passwordRejected': 'Windows did not accept that password. Try again.',
139
+ 'keepalive.serviceInstalled': 'Service {id} is installed and running.',
140
+ 'keepalive.wrongAccount': 'Run this as the account that will use herdr-remote, not another administrator account.',
141
+ 'keepalive.pressEnter': 'Press Enter to close this window.',
134
142
  'herdr.title': 'Herdr integration',
135
143
  'herdr.socketPath': 'Socket path',
136
144
  'herdr.args': 'Extra arguments',
@@ -219,5 +227,17 @@ const en = {
219
227
  'update.mirrorBehind': '{registries} still has an older version; installing from {source}',
220
228
  'update.cannot.source': 'Source checkout — update with git',
221
229
  'update.cannot.linked': 'Linked package — managed by npm link',
230
+ 'relayUpdate.check': 'Relay {version}: check for updates',
231
+ 'relayUpdate.checking': 'Relay: checking npm…',
232
+ 'relayUpdate.upToDate': 'Relay up to date ({version})',
233
+ 'relayUpdate.available': 'Relay {latest} available, installed {installed} (press Enter to install)',
234
+ 'relayUpdate.needsCli': 'Relay {latest} needs a newer herdr-remote: update herdr-remote first',
235
+ 'relayUpdate.updating': 'Installing relay {version}…',
236
+ 'relayUpdate.updatingRetry': 'Installing relay {version}… (npm has not caught up yet, attempt {attempt})',
237
+ 'relayUpdate.restart': 'Relay {version} installed, {running} still running (press Enter to restart)',
238
+ 'relayUpdate.restarting': 'Restarting herdr-remote…',
239
+ 'relayUpdate.restarted': 'herdr-remote restarted; the relay now runs {version}.',
240
+ 'relayUpdate.errorNotApplied': 'npm finished, but relay {installed} is still installed',
241
+ 'relayUpdate.banner': 'Relay {latest} is out (installed {current}). Press 7 and Enter on the relay row to install.',
222
242
  };
223
243
  export default en;
package/dist/i18n/zh.js CHANGED
@@ -130,6 +130,14 @@ const zh = {
130
130
  'keepalive.enableLinger': '开启开机自启',
131
131
  'keepalive.lingerDone': '已为 {username} 开启开机自启。',
132
132
  'keepalive.fallbackNote': '无系统服务管理器,使用后台守护进程,重启后不自动恢复。',
133
+ 'keepalive.serviceNote': '以你的账户作为 Windows 服务运行,开机后、登录前自动启动。安装或移除需要管理员权限。',
134
+ 'keepalive.serviceWindowHint': '请在随后打开的管理员窗口中完成安装。',
135
+ 'keepalive.elevationCancelled': '未授予管理员权限,未安装服务。',
136
+ 'keepalive.passwordPrompt': '请输入 {account} 的 Windows 密码(服务将以你的账户登录):',
137
+ 'keepalive.passwordRejected': 'Windows 不接受这个密码,请重试。',
138
+ 'keepalive.serviceInstalled': '服务 {id} 已安装并正在运行。',
139
+ 'keepalive.wrongAccount': '请使用将运行 herdr-remote 的账户执行此操作,不要换用其他管理员账户。',
140
+ 'keepalive.pressEnter': '按 Enter 关闭此窗口。',
133
141
  'herdr.title': 'Herdr 集成',
134
142
  'herdr.socketPath': '套接字路径',
135
143
  'herdr.args': '附加参数',
@@ -218,5 +226,17 @@ const zh = {
218
226
  'update.mirrorBehind': '{registries} 仍是旧版本,将从 {source} 安装',
219
227
  'update.cannot.source': '源码目录运行,请使用 git 更新',
220
228
  'update.cannot.linked': 'npm link 工作副本,由 npm link 管理',
229
+ 'relayUpdate.check': 'Relay {version}:检查更新',
230
+ 'relayUpdate.checking': 'Relay:查询 npm 中…',
231
+ 'relayUpdate.upToDate': 'Relay 已是最新({version})',
232
+ 'relayUpdate.available': 'Relay 新版本 {latest} 可用,当前 {installed}(按 Enter 安装)',
233
+ 'relayUpdate.needsCli': 'Relay {latest} 需要更新 herdr-remote,请先更新上一行',
234
+ 'relayUpdate.updating': '安装 relay {version} 中…',
235
+ 'relayUpdate.updatingRetry': '安装 relay {version} 中…(npm 尚未同步,第 {attempt} 次尝试)',
236
+ 'relayUpdate.restart': 'Relay {version} 已安装,运行中的仍是 {running}(按 Enter 重启生效)',
237
+ 'relayUpdate.restarting': '正在重启 herdr-remote…',
238
+ 'relayUpdate.restarted': 'herdr-remote 已重启,relay 现在运行 {version}。',
239
+ 'relayUpdate.errorNotApplied': 'npm 已完成,但安装的仍是 relay {installed}',
240
+ 'relayUpdate.banner': 'Relay {latest} 已发布(当前 {current}),按 7 进入「语言与关于」,在 Relay 一行按 Enter 安装。',
221
241
  };
222
242
  export default zh;
@@ -25,6 +25,7 @@ function start() {
25
25
  cwd: PACKAGE_ROOT,
26
26
  env: { ...process.env, HERDR_REMOTE_SERVICE: '1', ...serviceEnvironment() },
27
27
  detached: true,
28
+ windowsHide: true,
28
29
  stdio: ['ignore', logFd, logFd],
29
30
  });
30
31
  child.unref();
@@ -48,6 +49,11 @@ function stopAndForget() {
48
49
  fs.rmSync(fallbackPidPath(), { force: true });
49
50
  return { ok: true, stoppedPid: pid };
50
51
  }
52
+ export function detachedLogsHint(platform = process.platform, logFile = logPath('supervisor')) {
53
+ if (platform === 'win32')
54
+ return `Get-Content -Wait -Tail 40 "${logFile}"`;
55
+ return `tail -f ${logFile}`;
56
+ }
51
57
  export const detached = {
52
58
  name: 'supervisor',
53
59
  status() {
@@ -63,8 +69,12 @@ export const detached = {
63
69
  state: alive ? 'running' : 'stopped',
64
70
  };
65
71
  },
66
- install: start,
67
- uninstall: stopAndForget,
72
+ async install() {
73
+ return start();
74
+ },
75
+ async uninstall() {
76
+ return stopAndForget();
77
+ },
68
78
  restart() {
69
79
  stopAndForget();
70
80
  return start();
@@ -73,6 +83,6 @@ export const detached = {
73
83
  stopAndForget();
74
84
  },
75
85
  logsHint() {
76
- return `tail -f ${logPath('supervisor')}`;
86
+ return detachedLogsHint();
77
87
  },
78
88
  };
@@ -13,6 +13,7 @@ import { loadConfig } from '../config.js';
13
13
  import { detached } from './detached.js';
14
14
  import { launchd } from './launchd.js';
15
15
  import { systemd } from './systemd.js';
16
+ import { windowsService } from './windows-service.js';
16
17
  export { serviceEnvironment, servicePath } from './environment.js';
17
18
  export { escapeXml, renderLaunchdPlist } from './launchd.js';
18
19
  export { renderSystemdUnit, systemdEnvironmentLine } from './systemd.js';
@@ -36,10 +37,12 @@ function commandExists(command) {
36
37
  });
37
38
  }
38
39
  /** Which manager to use given the platform, the user's preference and reality. */
39
- export function detectManager(preference = 'auto') {
40
+ export function detectManager(preference = 'auto', platform = process.platform) {
40
41
  if (preference && preference !== 'auto')
41
42
  return preference;
42
- if (process.platform === 'linux') {
43
+ if (platform === 'win32')
44
+ return 'windows-service';
45
+ if (platform === 'linux') {
43
46
  // `systemctl --user` needs a user bus; containers and bare TTY logins often
44
47
  // have systemd installed but no session bus, where it would fail at runtime.
45
48
  if (commandExists('systemctl') &&
@@ -48,7 +51,7 @@ export function detectManager(preference = 'auto') {
48
51
  }
49
52
  return 'supervisor';
50
53
  }
51
- if (process.platform === 'darwin') {
54
+ if (platform === 'darwin') {
52
55
  return commandExists('launchctl') ? 'launchd' : 'supervisor';
53
56
  }
54
57
  return 'supervisor';
@@ -63,6 +66,8 @@ function backendOf(config) {
63
66
  return systemd;
64
67
  if (manager === 'launchd')
65
68
  return launchd;
69
+ if (manager === 'windows-service')
70
+ return windowsService;
66
71
  return detached;
67
72
  }
68
73
  export function status(config = loadConfig()) {
@@ -70,15 +75,15 @@ export function status(config = loadConfig()) {
70
75
  return { manager: 'none', installed: false, active: false, enabled: false };
71
76
  return backendOf(config).status();
72
77
  }
73
- export function install(config = loadConfig()) {
78
+ export async function install(config = loadConfig()) {
74
79
  if (managerOf(config) === 'none')
75
80
  throw new Error('keep-alive is disabled in the configuration');
76
81
  const backend = backendOf(config);
77
- return { manager: backend.name, ...backend.install() };
82
+ return { manager: backend.name, ...(await backend.install()) };
78
83
  }
79
- export function uninstall(config = loadConfig()) {
84
+ export async function uninstall(config = loadConfig()) {
80
85
  const backend = backendOf(config);
81
- return { manager: backend.name, ...backend.uninstall() };
86
+ return { manager: backend.name, ...(await backend.uninstall()) };
82
87
  }
83
88
  /** Restart whatever manages the services, so config edits take effect. */
84
89
  export function restart(config = loadConfig()) {
@@ -99,7 +104,10 @@ export function stopManaged(config = loadConfig()) {
99
104
  }
100
105
  export function enableLinger() {
101
106
  const username = os.userInfo().username;
102
- const result = spawnSync('loginctl', ['enable-linger', username], { encoding: 'utf8' });
107
+ const result = spawnSync('loginctl', ['enable-linger', username], {
108
+ encoding: 'utf8',
109
+ windowsHide: true,
110
+ });
103
111
  if (result.status !== 0) {
104
112
  throw new Error(String(result.stderr || '').trim() || 'loginctl enable-linger failed');
105
113
  }
@@ -58,11 +58,11 @@ function serviceTarget() {
58
58
  return `${domainTarget()}/${LAUNCHD_LABEL}`;
59
59
  }
60
60
  function launchctl(args) {
61
- return spawnSync('launchctl', args, { encoding: 'utf8' });
61
+ return spawnSync('launchctl', args, { encoding: 'utf8', windowsHide: true });
62
62
  }
63
63
  /** A launchctl call whose outcome does not matter (bootout of nothing, say). */
64
64
  function launchctlQuietly(args) {
65
- spawnSync('launchctl', args, { stdio: 'ignore' });
65
+ spawnSync('launchctl', args, { stdio: 'ignore', windowsHide: true });
66
66
  }
67
67
  export const launchd = {
68
68
  name: 'launchd',
@@ -81,7 +81,7 @@ export const launchd = {
81
81
  state: result.status === 0 ? 'loaded' : 'not loaded',
82
82
  };
83
83
  },
84
- install() {
84
+ async install() {
85
85
  const plistPath = launchdPlistPath();
86
86
  ensureDir(path.dirname(plistPath));
87
87
  ensureDir(stateDir());
@@ -97,7 +97,7 @@ export const launchd = {
97
97
  launchctlQuietly(['enable', serviceTarget()]);
98
98
  return { ok: true, unitPath: plistPath };
99
99
  },
100
- uninstall() {
100
+ async uninstall() {
101
101
  const plistPath = launchdPlistPath();
102
102
  launchctlQuietly(['bootout', serviceTarget()]);
103
103
  fs.rmSync(plistPath, { force: true });
@@ -46,7 +46,11 @@ WantedBy=default.target
46
46
  `;
47
47
  }
48
48
  function systemctl(args) {
49
- return spawnSync('systemctl', ['--user', ...args], { encoding: 'utf8', stdio: 'pipe' });
49
+ return spawnSync('systemctl', ['--user', ...args], {
50
+ encoding: 'utf8',
51
+ stdio: 'pipe',
52
+ windowsHide: true,
53
+ });
50
54
  }
51
55
  /** Run a systemctl command that must succeed, throwing its stderr when it does not. */
52
56
  function systemctlOrThrow(args, failure) {
@@ -63,7 +67,7 @@ export const systemd = {
63
67
  return { manager: 'systemd', installed: false, active: false, enabled: false };
64
68
  const active = systemctl(['is-active', SYSTEMD_UNIT_NAME]);
65
69
  const enabled = systemctl(['is-enabled', SYSTEMD_UNIT_NAME]);
66
- const lingering = spawnSync('loginctl', ['show-user', os.userInfo().username, '--property=Linger'], { encoding: 'utf8' });
70
+ const lingering = spawnSync('loginctl', ['show-user', os.userInfo().username, '--property=Linger'], { encoding: 'utf8', windowsHide: true });
67
71
  return {
68
72
  manager: 'systemd',
69
73
  installed: true,
@@ -76,7 +80,7 @@ export const systemd = {
76
80
  state: String(active.stdout || active.stderr || '').trim(),
77
81
  };
78
82
  },
79
- install() {
83
+ async install() {
80
84
  const unitPath = systemdUnitPath();
81
85
  ensureDir(path.dirname(unitPath));
82
86
  fs.writeFileSync(unitPath, renderSystemdUnit({ environment: serviceEnvironment() }), {
@@ -86,7 +90,7 @@ export const systemd = {
86
90
  systemctlOrThrow(['enable', '--now', SYSTEMD_UNIT_NAME], 'systemctl --user enable --now failed');
87
91
  return { ok: true, unitPath, hint: `loginctl enable-linger ${os.userInfo().username}` };
88
92
  },
89
- uninstall() {
93
+ async uninstall() {
90
94
  const unitPath = systemdUnitPath();
91
95
  systemctl(['disable', '--now', SYSTEMD_UNIT_NAME]);
92
96
  fs.rmSync(unitPath, { force: true });