herdr-remote 0.2.27 → 0.2.28

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/README.md CHANGED
@@ -29,10 +29,11 @@ through Windows Defender Firewall on private networks.
29
29
 
30
30
  ```bash
31
31
  herdr-remote start | stop | restart
32
+ herdr-remote update [--json] # Install the latest release and restart services
32
33
  herdr-remote status [--json]
33
34
  herdr-remote pair [--json]
34
35
  herdr-remote url
35
- herdr-remote admin-broker install | uninstall | status # Windows
36
+ herdr-remote admin-broker install | uninstall | restart | status # Windows
36
37
  herdr-remote keepalive install | uninstall | restart | status
37
38
  herdr-remote plugin link | unlink | status
38
39
  herdr-remote --lang zh|en
@@ -43,7 +44,8 @@ privileges and start it for the signed-in account. Choose **Set up admin termina
43
44
  approve the one-time Windows prompt under the same account as Herdr Remote; the WebUI's Admin
44
45
  button then opens an elevated PowerShell as a new Herdr tab without another UAC prompt. The CLI
45
46
  `admin-broker install` and `admin-broker uninstall` commands are also available for administrators.
46
- Use `admin-broker status` to check the broker.
47
+ Use `admin-broker status` to check the broker. `herdr-remote update` restarts the broker on the new
48
+ release unless an admin terminal is open; `admin-broker restart` does it later, without elevation.
47
49
 
48
50
  ## Herdr Plugin
49
51
 
package/README.zh-CN.md CHANGED
@@ -29,16 +29,17 @@ Defender 防火墙提示中允许 `node.exe` 通过专用网络通信。
29
29
 
30
30
  ```bash
31
31
  herdr-remote start | stop | restart
32
+ herdr-remote update [--json] # 安装最新版本并重启服务
32
33
  herdr-remote status [--json]
33
34
  herdr-remote pair [--json]
34
35
  herdr-remote url
35
- herdr-remote admin-broker install | uninstall | status # Windows
36
+ herdr-remote admin-broker install | uninstall | restart | status # Windows
36
37
  herdr-remote keepalive install | uninstall | restart | status
37
38
  herdr-remote plugin link | unlink | status
38
39
  herdr-remote --lang zh|en
39
40
  ```
40
41
 
41
- 在 Windows 上,可在 TUI 的「保活」页面注册以当前账户最高权限运行的计划任务并启动 broker。选择「设置管理员终端」,使用运行 Herdr Remote 的同一账户批准一次 Windows 提权提示;之后,WebUI 的“管理员”按钮会在 Herdr 中新建一个管理员 PowerShell 标签页,不再重复弹出 UAC。也可以使用 `admin-broker install` 和 `admin-broker uninstall` 命令管理任务,使用 `admin-broker status` 检查 broker。
42
+ 在 Windows 上,可在 TUI 的「保活」页面注册以当前账户最高权限运行的计划任务并启动 broker。选择「设置管理员终端」,使用运行 Herdr Remote 的同一账户批准一次 Windows 提权提示;之后,WebUI 的“管理员”按钮会在 Herdr 中新建一个管理员 PowerShell 标签页,不再重复弹出 UAC。也可以使用 `admin-broker install` 和 `admin-broker uninstall` 命令管理任务,使用 `admin-broker status` 检查 broker。`herdr-remote update` 会让 broker 以新版本重启(有管理员终端开着时除外),之后也可以用 `admin-broker restart` 重启,无需提权。
42
43
 
43
44
  ## Herdr 插件
44
45
 
package/dist/cli.js CHANGED
@@ -26,7 +26,8 @@ Commands:
26
26
  pair [--json] Create pairing code
27
27
  url Print browser access URL
28
28
  run [--daemon] Run services in foreground (keep-alive)
29
- admin-broker install | uninstall | status (Windows)
29
+ update [--json] Install the latest release and restart services
30
+ admin-broker install | uninstall | restart | status (Windows)
30
31
  admin-shell Elevated PowerShell in this terminal (Windows)
31
32
  keepalive <action> install | uninstall | restart | status
32
33
  plugin <action> link | unlink | status (Herdr plugin)
@@ -219,6 +220,18 @@ async function main(argv = process.argv.slice(2)) {
219
220
  process.stdout.write(`${resolvePublicUrl(config, preferredLanAddress())}\n`);
220
221
  return;
221
222
  }
223
+ case 'update': {
224
+ const { runUpdate } = await import('./update-command.js');
225
+ const outcome = await runUpdate(t, {
226
+ config,
227
+ ...(flags.json ? { report: () => { } } : {}),
228
+ });
229
+ if (flags.json)
230
+ printJson(outcome);
231
+ if (!outcome.ok)
232
+ process.exitCode = 1;
233
+ return;
234
+ }
222
235
  case 'run': {
223
236
  const { runForeground } = await import('./supervisor.js');
224
237
  await runForeground({ logToFiles: Boolean(flags.daemon) });
@@ -235,6 +248,12 @@ async function main(argv = process.argv.slice(2)) {
235
248
  broker.uninstallAdminBroker(t);
236
249
  printJson({ installed: false });
237
250
  }
251
+ else if (action === 'restart') {
252
+ if ((await broker.stopAdminBroker()) === 'busy')
253
+ throw new Error(t('adminBroker.busy'));
254
+ broker.startAdminBrokerTask(t);
255
+ printJson({ available: await broker.waitForAdminBroker() });
256
+ }
238
257
  else if (action === 'status') {
239
258
  printJson({ available: await broker.adminBrokerStatus() });
240
259
  }
@@ -1,13 +1,17 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
2
3
  import net from 'node:net';
3
4
  import path from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import { PtySession } from '../pty-session.js';
6
- import { PACKAGE_ROOT, stateDir } from '../paths.js';
7
+ import { stateDir } from '../paths.js';
7
8
  import { ADMIN_BROKER_MAX_MESSAGE_BYTES, adminBrokerTaskName, adminBrokerTokenMatches, connectAdminBroker, listenAdminBroker, publishAdminBrokerEndpoint, sendAdminBrokerMessage, } from './adminBrokerEndpoint.js';
8
9
  const BROKER_PING_TIMEOUT_MS = 1_000;
9
10
  const BROKER_START_TIMEOUT_MS = 5_000;
10
11
  const BROKER_START_POLL_MS = 250;
12
+ const BROKER_TASK_END_TIMEOUT_MS = 10_000;
13
+ /** Time for a stopping broker's goodbye to reach the caller before the process ends. */
14
+ const BROKER_EXIT_GRACE_MS = 1_000;
11
15
  function powershellQuote(value) {
12
16
  return `'${value.replaceAll("'", "''")}'`;
13
17
  }
@@ -28,19 +32,20 @@ function runPowerShell(script, t) {
28
32
  throw new Error(t('adminBroker.needsElevation'));
29
33
  }
30
34
  }
31
- export function installAdminBroker(t) {
32
- if (process.platform !== 'win32')
33
- throw new Error(t('adminBroker.windowsOnly'));
35
+ /** Registers and starts the broker's logon task; run elevated. */
36
+ export function adminBrokerTaskScript() {
34
37
  const brokerPath = fileURLToPath(import.meta.url);
35
38
  // Pinned so the broker publishes its endpoint where this install's callers look.
36
39
  const brokerCommand = `$env:HERDR_REMOTE_STATE_DIR = ${powershellQuote(stateDir())}; & ${powershellQuote(process.execPath)} ${powershellQuote(brokerPath)}`;
37
40
  const encodedBrokerCommand = Buffer.from(brokerCommand, 'utf16le').toString('base64');
38
41
  const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
39
- const script = `
42
+ // Not PACKAGE_ROOT: npm updates the package by renaming its directory, and
43
+ // Windows refuses while it is the working directory of this long-lived task.
44
+ return `
40
45
  $ErrorActionPreference = 'Stop'
41
46
  $taskName = ${powershellQuote(adminBrokerTaskName())}
42
47
  $user = [Security.Principal.WindowsIdentity]::GetCurrent().Name
43
- $action = New-ScheduledTaskAction -Execute ${powershellQuote(powershell)} -Argument ${powershellQuote(`-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ${encodedBrokerCommand}`)} -WorkingDirectory ${powershellQuote(PACKAGE_ROOT)}
48
+ $action = New-ScheduledTaskAction -Execute ${powershellQuote(powershell)} -Argument ${powershellQuote(`-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ${encodedBrokerCommand}`)} -WorkingDirectory ${powershellQuote(stateDir())}
44
49
  $trigger = New-ScheduledTaskTrigger -AtLogOn -User $user
45
50
  $principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest
46
51
  $settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Seconds 0)
@@ -48,7 +53,13 @@ Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
48
53
  Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
49
54
  Start-ScheduledTask -TaskName $taskName
50
55
  `;
51
- runPowerShell(script, t);
56
+ }
57
+ export function installAdminBroker(t) {
58
+ if (process.platform !== 'win32')
59
+ throw new Error(t('adminBroker.windowsOnly'));
60
+ // The task cannot start in a working directory that does not exist yet.
61
+ fs.mkdirSync(stateDir(), { recursive: true });
62
+ runPowerShell(adminBrokerTaskScript(), t);
52
63
  }
53
64
  export function uninstallAdminBroker(t) {
54
65
  if (process.platform !== 'win32')
@@ -60,36 +71,69 @@ Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
60
71
  Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
61
72
  `, t);
62
73
  }
63
- export function adminBrokerStatus() {
74
+ /** Send the broker one request and read the type of its one reply, or null without one. */
75
+ function requestAdminBroker(type) {
64
76
  let broker;
65
77
  try {
66
78
  broker = connectAdminBroker();
67
79
  }
68
80
  catch {
69
- return Promise.resolve(false);
81
+ return Promise.resolve(null);
70
82
  }
71
83
  const { socket, token } = broker;
72
84
  return new Promise((resolve) => {
73
85
  let settled = false;
74
86
  let response = '';
75
- const finish = (available) => {
87
+ const finish = (reply) => {
76
88
  if (settled)
77
89
  return;
78
90
  settled = true;
79
91
  socket.destroy();
80
- resolve(available);
92
+ resolve(reply);
81
93
  };
82
- socket.setTimeout(BROKER_PING_TIMEOUT_MS, () => finish(false));
94
+ socket.setTimeout(BROKER_PING_TIMEOUT_MS, () => finish(null));
83
95
  socket.setEncoding('utf8');
84
- socket.on('connect', () => sendAdminBrokerMessage(socket, { type: 'ping', token }));
96
+ socket.on('connect', () => sendAdminBrokerMessage(socket, { type, token }));
85
97
  socket.on('data', (chunk) => {
86
98
  response += chunk;
87
- if (response.includes('\n'))
88
- finish(response.includes('"type":"pong"'));
99
+ const newline = response.indexOf('\n');
100
+ if (newline < 0)
101
+ return;
102
+ try {
103
+ finish(String(JSON.parse(response.slice(0, newline)).type));
104
+ }
105
+ catch {
106
+ finish(null);
107
+ }
89
108
  });
90
- socket.on('error', () => finish(false));
109
+ socket.on('error', () => finish(null));
91
110
  });
92
111
  }
112
+ export async function adminBrokerStatus() {
113
+ return (await requestAdminBroker('ping')) === 'pong';
114
+ }
115
+ /**
116
+ * Ask the broker to exit so its task can start it on a new release. It stays
117
+ * while an administrator terminal is open: that terminal may be the one asking.
118
+ * A broker from before this request answers neither way.
119
+ */
120
+ export async function stopAdminBroker() {
121
+ const reply = await requestAdminBroker('shutdown');
122
+ return reply === 'bye' ? 'stopped' : reply === 'busy' ? 'busy' : 'unavailable';
123
+ }
124
+ /** Start the broker's task now, which the account that registered it may do unelevated. */
125
+ export function startAdminBrokerTask(t) {
126
+ if (process.platform !== 'win32')
127
+ throw new Error(t('adminBroker.windowsOnly'));
128
+ runPowerShell(`
129
+ $ErrorActionPreference = 'Stop'
130
+ $taskName = ${powershellQuote(adminBrokerTaskName())}
131
+ # A broker that was just stopped may still be ending, and IgnoreNew drops a start until it has.
132
+ $deadline = [DateTime]::UtcNow.AddMilliseconds(${BROKER_TASK_END_TIMEOUT_MS})
133
+ while ((Get-ScheduledTask -TaskName $taskName).State -eq 'Running' -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds ${BROKER_START_POLL_MS} }
134
+ Start-ScheduledTask -TaskName $taskName
135
+ `, t);
136
+ }
93
137
  /** The task starts the broker a moment after it is registered; wait for it to answer. */
94
138
  export async function waitForAdminBroker() {
95
139
  const deadline = Date.now() + BROKER_START_TIMEOUT_MS;
@@ -105,7 +149,7 @@ function writeError(socket, error) {
105
149
  sendAdminBrokerMessage(socket, { type: 'error', message });
106
150
  socket.end();
107
151
  }
108
- function startBrokerPty(socket, message) {
152
+ function startBrokerPty(socket, message, spawn) {
109
153
  if (typeof message.command !== 'string' || !message.command || message.command.length > 4096)
110
154
  throw new Error('Invalid administrator terminal command.');
111
155
  if (path.basename(message.command).toLowerCase() !== 'powershell.exe')
@@ -130,6 +174,7 @@ function startBrokerPty(socket, message) {
130
174
  platform: 'win32',
131
175
  // A plain PowerShell prompt does not need the Herdr UI's Kitty or mouse modes.
132
176
  fastWindowsPty: true,
177
+ spawn,
133
178
  });
134
179
  pty.start({
135
180
  cols: PtySession.clampDimension(message.cols, PtySession.DEFAULT_COLS),
@@ -151,8 +196,9 @@ function startBrokerPty(socket, message) {
151
196
  return pty;
152
197
  }
153
198
  /** Listen on loopback and publish the endpoint; every request must carry its token. */
154
- export function serveAdminBroker() {
199
+ export function serveAdminBroker({ spawn, onShutdown = () => { }, } = {}) {
155
200
  let token = '';
201
+ const sessions = new Set();
156
202
  const server = net.createServer((socket) => {
157
203
  socket.setEncoding('utf8');
158
204
  let pending = '';
@@ -189,12 +235,23 @@ export function serveAdminBroker() {
189
235
  socket.end();
190
236
  return;
191
237
  }
238
+ if (message.type === 'shutdown') {
239
+ const busy = sessions.size > 0;
240
+ sendAdminBrokerMessage(socket, { type: busy ? 'busy' : 'bye' });
241
+ socket.end();
242
+ if (!busy) {
243
+ server.close();
244
+ onShutdown();
245
+ }
246
+ return;
247
+ }
192
248
  if (message.type !== 'start') {
193
249
  writeError(socket, new Error('Expected an administrator terminal start request.'));
194
250
  return;
195
251
  }
196
252
  try {
197
- pty = startBrokerPty(socket, message);
253
+ pty = startBrokerPty(socket, message, spawn);
254
+ sessions.add(pty);
198
255
  }
199
256
  catch (error) {
200
257
  writeError(socket, error);
@@ -218,8 +275,14 @@ export function serveAdminBroker() {
218
275
  }
219
276
  }
220
277
  });
221
- socket.on('close', () => pty?.kill());
222
- socket.on('error', () => pty?.kill());
278
+ const release = () => {
279
+ if (!pty)
280
+ return;
281
+ pty.kill();
282
+ sessions.delete(pty);
283
+ };
284
+ socket.on('close', release);
285
+ socket.on('error', release);
223
286
  });
224
287
  listenAdminBroker(server, (port) => {
225
288
  token = publishAdminBrokerEndpoint(port);
@@ -229,7 +292,9 @@ export function serveAdminBroker() {
229
292
  function runAdminBroker() {
230
293
  if (process.platform !== 'win32')
231
294
  throw new Error('Administrator PTYs are only supported on Windows.');
232
- serveAdminBroker().on('error', (error) => {
295
+ serveAdminBroker({
296
+ onShutdown: () => setTimeout(() => process.exit(0), BROKER_EXIT_GRACE_MS).unref(),
297
+ }).on('error', (error) => {
233
298
  process.stderr.write(`herdr-remote administrator broker: ${error.message}\n`);
234
299
  });
235
300
  }
@@ -54,6 +54,8 @@ export class UpdateReport {
54
54
  updateAvailable: compareVersions(result.latest, running) > 0,
55
55
  // Updated on disk, still running the old code: a restart finishes it.
56
56
  restartPending: compareVersions(installed, running) > 0,
57
+ // Older connectors leave this out, and their windows keep the npm steps.
58
+ updateCommand: true,
57
59
  });
58
60
  })
59
61
  .catch(() => {
package/dist/i18n/en.js CHANGED
@@ -86,6 +86,7 @@ const en = {
86
86
  'services.logHost': 'Host connector',
87
87
  'adminBroker.windowsOnly': 'The administrator terminal broker only runs on Windows.',
88
88
  'adminBroker.needsElevation': 'Open the Windows TUI and choose Set up admin terminal access. Approve the one-time Windows prompt under the same account as Herdr Remote.',
89
+ 'adminBroker.busy': 'An admin terminal is open; close it before restarting the broker.',
89
90
  'adminBroker.title': 'Admin terminal broker',
90
91
  'adminBroker.checking': 'Checking…',
91
92
  'adminBroker.ready': 'Ready',
@@ -244,6 +245,14 @@ const en = {
244
245
  'update.mirrorBehind': '{registries} still has an older version; installing from {source}',
245
246
  'update.cannot.source': 'Source checkout — update with git',
246
247
  'update.cannot.linked': 'Linked package — managed by npm link',
248
+ 'update.stoppingServices': 'Stopping services while npm replaces their files…',
249
+ 'update.restarting': 'Restarting services on the new release…',
250
+ 'update.notRunning': 'Services were not running and were left stopped. Start them with: herdr-remote start',
251
+ 'update.restartFailed': 'Installed, but the services did not restart: {message}',
252
+ 'update.brokerRestarted': 'Admin terminal broker restarted.',
253
+ 'update.brokerRestartFailed': 'The admin terminal broker did not start again. Sign out of Windows and back in, or run herdr-remote admin-broker restart.',
254
+ 'update.brokerBusy': 'An admin terminal is open, so the broker keeps running the previous release. Close admin terminals, then run herdr-remote admin-broker restart.',
255
+ 'update.errorBusy': "A process still holds the herdr-remote folder; an admin terminal broker set up by an older release does. In an administrator PowerShell run: Get-ScheduledTask -TaskName 'HerdrRemoteAdminBroker-*' | Stop-ScheduledTask. Then run herdr-remote update again here, and afterwards herdr-remote admin-broker install as administrator.",
247
256
  'relayUpdate.check': 'Relay {version}: check for updates',
248
257
  'relayUpdate.checking': 'Relay: checking npm…',
249
258
  'relayUpdate.upToDate': 'Relay up to date ({version})',
@@ -254,6 +263,7 @@ const en = {
254
263
  'relayUpdate.restart': 'Relay {version} installed, {running} still running (press Enter to restart)',
255
264
  'relayUpdate.restarting': 'Restarting herdr-remote…',
256
265
  'relayUpdate.restarted': 'herdr-remote restarted; the relay now runs {version}.',
266
+ 'relayUpdate.done': 'Relay updated to {version}',
257
267
  'relayUpdate.errorNotApplied': 'npm finished, but relay {installed} is still installed',
258
268
  'relayUpdate.banner': 'Relay {latest} is out (installed {current}). Press 7 and Enter on the relay row to install.',
259
269
  };
package/dist/i18n/zh.js CHANGED
@@ -85,6 +85,7 @@ const zh = {
85
85
  'services.logHost': '主机连接器',
86
86
  'adminBroker.windowsOnly': '管理员终端 broker 只支持 Windows。',
87
87
  'adminBroker.needsElevation': '请打开 Windows TUI 并选择“设置管理员终端”,使用运行 Herdr Remote 的同一账户批准一次 Windows 提权提示。',
88
+ 'adminBroker.busy': '有管理员终端开着,请先关闭再重启 broker。',
88
89
  'adminBroker.title': '管理员终端 broker',
89
90
  'adminBroker.checking': '正在检查…',
90
91
  'adminBroker.ready': '已就绪',
@@ -243,6 +244,14 @@ const zh = {
243
244
  'update.mirrorBehind': '{registries} 仍是旧版本,将从 {source} 安装',
244
245
  'update.cannot.source': '源码目录运行,请使用 git 更新',
245
246
  'update.cannot.linked': 'npm link 工作副本,由 npm link 管理',
247
+ 'update.stoppingServices': '替换文件期间先停止服务…',
248
+ 'update.restarting': '正在用新版本重启服务…',
249
+ 'update.notRunning': '服务之前没有运行,保持停止。需要时执行:herdr-remote start',
250
+ 'update.restartFailed': '已安装,但服务没能重启:{message}',
251
+ 'update.brokerRestarted': '管理员终端 broker 已重启。',
252
+ 'update.brokerRestartFailed': '管理员终端 broker 没有重新启动。重新登录 Windows,或运行 herdr-remote admin-broker restart。',
253
+ 'update.brokerBusy': '有管理员终端开着,broker 继续运行旧版本。关闭所有管理员终端后运行 herdr-remote admin-broker restart。',
254
+ 'update.errorBusy': "仍有进程占用 herdr-remote 的安装目录,旧版本设置的管理员终端 broker 会这样。先在管理员 PowerShell 中执行:Get-ScheduledTask -TaskName 'HerdrRemoteAdminBroker-*' | Stop-ScheduledTask,再回到这里重新运行 herdr-remote update,最后以管理员身份运行 herdr-remote admin-broker install。",
246
255
  'relayUpdate.check': 'Relay {version}:检查更新',
247
256
  'relayUpdate.checking': 'Relay:查询 npm 中…',
248
257
  'relayUpdate.upToDate': 'Relay 已是最新({version})',
@@ -253,6 +262,7 @@ const zh = {
253
262
  'relayUpdate.restart': 'Relay {version} 已安装,运行中的仍是 {running}(按 Enter 重启生效)',
254
263
  'relayUpdate.restarting': '正在重启 herdr-remote…',
255
264
  'relayUpdate.restarted': 'herdr-remote 已重启,relay 现在运行 {version}。',
265
+ 'relayUpdate.done': 'Relay 已更新至 {version}',
256
266
  'relayUpdate.errorNotApplied': 'npm 已完成,但安装的仍是 relay {installed}',
257
267
  'relayUpdate.banner': 'Relay {latest} 已发布(当前 {current}),按 7 进入「语言与关于」,在 Relay 一行按 Enter 安装。',
258
268
  };
@@ -5,7 +5,7 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { ensureDir, readJson, writeJsonAtomic } from 'herdr-remote-relay/state';
7
7
  import { pidAlive } from '../lib/process.js';
8
- import { logPath, PACKAGE_ROOT, stateDir } from '../paths.js';
8
+ import { logPath, stateDir } from '../paths.js';
9
9
  import { cliEntryPoint, serviceEnvironment } from './environment.js';
10
10
  function fallbackPidPath() {
11
11
  return path.join(stateDir(), 'supervisor.pid');
@@ -22,7 +22,8 @@ function start() {
22
22
  const logFd = fs.openSync(logPath('supervisor'), 'a');
23
23
  try {
24
24
  const child = spawn(process.execPath, [cliEntryPoint(), 'run', '--daemon'], {
25
- cwd: PACKAGE_ROOT,
25
+ // Not PACKAGE_ROOT: npm renames it to update, which Windows refuses for a working directory.
26
+ cwd: stateDir(),
26
27
  env: { ...process.env, HERDR_REMOTE_SERVICE: '1', ...serviceEnvironment() },
27
28
  detached: true,
28
29
  windowsHide: true,
package/dist/lifecycle.js CHANGED
@@ -49,8 +49,12 @@ function restartAll(config = loadConfig()) {
49
49
  }
50
50
  return { ...restartServices(), managed: false };
51
51
  }
52
+ /** Whether the host connector is up, under a keep-alive manager or detached. */
53
+ function servicesRunning(config = loadConfig()) {
54
+ return Boolean(managerInUse(config)?.active || pidAlive(readRuntime().hostPid));
55
+ }
52
56
  /** Stop Windows keep-alive processes while npm replaces their package files. */
53
- async function whileServicesStopped(config, task, { platform = process.platform, isRunning = () => Boolean(managerInUse(config)?.active || pidAlive(readRuntime().hostPid)), stop = stopAll, start = startAll, } = {}) {
57
+ async function whileServicesStopped(config, task, { platform = process.platform, isRunning = () => servicesRunning(config), stop = stopAll, start = startAll, } = {}) {
54
58
  if (platform !== 'win32' || !isRunning())
55
59
  return task();
56
60
  await stop(config);
@@ -70,4 +74,4 @@ async function fullStatus(config = loadConfig()) {
70
74
  logsHint: keepalive.logsHint(config),
71
75
  };
72
76
  }
73
- export { startAll, stopAll, restartAll, fullStatus, managerInUse, whileServicesStopped };
77
+ export { startAll, stopAll, restartAll, fullStatus, managerInUse, servicesRunning, whileServicesStopped, };
@@ -0,0 +1,80 @@
1
+ // Where node-pty is loaded from.
2
+ //
3
+ // npm updates this package by renaming its directory and deleting the old one,
4
+ // and Windows refuses both while a process has a file in it loaded: node-pty's
5
+ // .node modules, or the OpenConsole.exe that ConPTY starts from beside them.
6
+ // The host connector and the admin broker hold those for as long as they run,
7
+ // so on Windows node-pty is loaded from a copy in the state directory, one per
8
+ // build. Copies are never deleted: a process of an older release may still
9
+ // have one loaded, and a build is a few megabytes.
10
+ import { createHash } from 'node:crypto';
11
+ import fs from 'node:fs';
12
+ import { createRequire } from 'node:module';
13
+ import path from 'node:path';
14
+ import { stateDir } from './paths.js';
15
+ const require = createRequire(import.meta.url);
16
+ /** What node-pty loads or starts; debug symbols are most of the prebuilds' size. */
17
+ const BINARY_EXTENSIONS = ['.node', '.dll', '.exe'];
18
+ /** The files a running node-pty needs, relative to its package directory. */
19
+ function runtimeFiles(source, platform, arch) {
20
+ const files = ['package.json'];
21
+ const collect = (directory, keep) => {
22
+ const absolute = path.join(source, directory);
23
+ if (!fs.existsSync(absolute))
24
+ return;
25
+ for (const entry of fs.readdirSync(absolute, { recursive: true, withFileTypes: true })) {
26
+ if (entry.isFile() && keep(entry.name))
27
+ files.push(path.relative(source, path.join(entry.parentPath, entry.name)));
28
+ }
29
+ };
30
+ collect('lib', (name) => name.endsWith('.js') && !name.endsWith('.test.js'));
31
+ const binary = (name) => BINARY_EXTENSIONS.includes(path.extname(name));
32
+ // Where node-pty looks for its modules: a local build, then the prebuilds.
33
+ collect(path.join('build', 'Release'), binary);
34
+ collect(path.join('prebuilds', `${platform}-${arch}`), binary);
35
+ return files;
36
+ }
37
+ /** Copy node-pty's run-time files under `directory`, once per build; returns the copy. */
38
+ export function copyNodePty(source, directory, platform, arch) {
39
+ const files = runtimeFiles(source, platform, arch);
40
+ const hash = createHash('sha256');
41
+ for (const file of files)
42
+ hash.update(`${file}\0`).update(fs.readFileSync(path.join(source, file)));
43
+ const target = path.join(directory, hash.digest('hex').slice(0, 16));
44
+ if (fs.existsSync(target))
45
+ return target;
46
+ // Renamed into place whole, so a copy that exists is complete.
47
+ const staging = `${target}.${process.pid}.tmp`;
48
+ for (const file of files) {
49
+ fs.mkdirSync(path.dirname(path.join(staging, file)), { recursive: true });
50
+ fs.copyFileSync(path.join(source, file), path.join(staging, file));
51
+ }
52
+ try {
53
+ fs.renameSync(staging, target);
54
+ }
55
+ catch (error) {
56
+ fs.rmSync(staging, { recursive: true, force: true });
57
+ // Another process put the same build in place first.
58
+ if (!fs.existsSync(target))
59
+ throw error;
60
+ }
61
+ return target;
62
+ }
63
+ export function loadNodePty({ platform = process.platform, arch = process.arch, source = path.dirname(require.resolve('node-pty/package.json')), directory = path.join(stateDir(), 'node-pty'), warn = (message) => process.stderr.write(`${message}\n`), } = {}) {
64
+ if (platform !== 'win32')
65
+ return require(source);
66
+ try {
67
+ return require(copyNodePty(source, directory, platform, arch));
68
+ }
69
+ catch (error) {
70
+ // Terminals still work from the package; only an update has to wait for them to end.
71
+ warn(`herdr-remote: could not copy node-pty out of the package: ${error.message}`);
72
+ return require(source);
73
+ }
74
+ }
75
+ let loaded = null;
76
+ /** node-pty, loaded on first use: only processes that start terminals pay for it. */
77
+ export function nodePty() {
78
+ loaded ??= loadNodePty();
79
+ return loaded;
80
+ }
@@ -1,5 +1,5 @@
1
1
  import os from 'node:os';
2
- import pty from 'node-pty';
2
+ import { nodePty } from './node-pty-loader.js';
3
3
  class PtySession {
4
4
  static DEFAULT_COLS = 100;
5
5
  static DEFAULT_ROWS = 30;
@@ -14,7 +14,7 @@ class PtySession {
14
14
  spawnPty;
15
15
  terminal;
16
16
  startedAt;
17
- constructor({ command, args = [], cwd = os.homedir(), socketPath, platform = process.platform, spawn: spawnPty = pty.spawn, fastWindowsPty = false, } = {}) {
17
+ constructor({ command, args = [], cwd = os.homedir(), socketPath, platform = process.platform, spawn: spawnPty = nodePty().spawn, fastWindowsPty = false, } = {}) {
18
18
  if (typeof command !== 'string' || command.length === 0)
19
19
  throw new TypeError('command must be a non-empty string');
20
20
  if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string'))
package/dist/service.js CHANGED
@@ -40,6 +40,7 @@ export function serviceSpecs(config = loadConfig(), state = ensureRuntime()) {
40
40
  name: 'relay',
41
41
  command: process.execPath,
42
42
  args: [relayBinPath()],
43
+ cwd: stateDir(),
43
44
  env: {
44
45
  // Let the shared web UI tell a private workstation relay apart from
45
46
  // the operator-facing self-hosted relay. Standalone relay installs
@@ -71,6 +72,7 @@ export function serviceSpecs(config = loadConfig(), state = ensureRuntime()) {
71
72
  name: 'host',
72
73
  command: process.execPath,
73
74
  args: [path.join(PACKAGE_ROOT, 'dist', 'connector', 'main.js')],
75
+ cwd: stateDir(),
74
76
  env: {
75
77
  // Captured here, where a terminal may still be attached, because the
76
78
  // connector itself usually runs detached with no terminal to ask.
@@ -96,7 +98,7 @@ function spawnDetached(spec) {
96
98
  const logFd = fs.openSync(logPath(spec.name), 'a');
97
99
  try {
98
100
  const child = spawn(spec.command, spec.args, {
99
- cwd: PACKAGE_ROOT,
101
+ cwd: spec.cwd,
100
102
  env: { ...baseEnvironment(), ...spec.env },
101
103
  detached: true,
102
104
  // A detached console program can open a visible window from a console-less Windows parent.
@@ -1,7 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { spawn } from 'node:child_process';
3
3
  import { loadConfig } from './config.js';
4
- import { logPath, PACKAGE_ROOT, stateDir } from './paths.js';
4
+ import { logPath, stateDir } from './paths.js';
5
5
  import { ensureDir } from 'herdr-remote-relay/state';
6
6
  import { ensureRuntime, managedPids, recordManagedPid, updateRuntime, } from './runtime.js';
7
7
  import { baseEnvironment, serviceSpecs } from './service.js';
@@ -131,7 +131,7 @@ class Supervisor {
131
131
  let child;
132
132
  try {
133
133
  child = spawn(entry.spec.command, entry.spec.args, {
134
- cwd: PACKAGE_ROOT,
134
+ cwd: entry.spec.cwd,
135
135
  env: { ...baseEnvironment(), ...entry.spec.env },
136
136
  windowsHide: true,
137
137
  stdio,
@@ -0,0 +1,120 @@
1
+ // `herdr-remote update`: install the newest release and restart what was running.
2
+ //
3
+ // The TUI's update row installs and leaves the restart to a second key press;
4
+ // a command run over SSH or from a script has nobody to press it. The restart
5
+ // is run by the release just installed, not by this process: this process
6
+ // still runs the old code, and would start the services as the old release
7
+ // described them.
8
+ import { spawnSync } from 'node:child_process';
9
+ import { loadConfig } from './config.js';
10
+ import { startAdminBrokerTask, stopAdminBroker, waitForAdminBroker, } from './connector/admin-broker.js';
11
+ import { cliEntryPoint } from './keepalive/environment.js';
12
+ import { servicesRunning, startAll, stopAll } from './lifecycle.js';
13
+ import { checkForRelayUpdate, performRelayUpdate } from './relay-updater.js';
14
+ import { checkForUpdate, performUpdate } from './updater.js';
15
+ const RESTART_TIMEOUT_MS = 120_000;
16
+ function restartWithInstalledRelease() {
17
+ const result = spawnSync(process.execPath, [cliEntryPoint(), 'restart', '--json'], {
18
+ encoding: 'utf8',
19
+ timeout: RESTART_TIMEOUT_MS,
20
+ windowsHide: true,
21
+ });
22
+ if (!result.error && result.status === 0)
23
+ return { ok: true, message: '' };
24
+ const output = String(result.stderr || result.stdout || '').trim();
25
+ return { ok: false, message: result.error?.message || output || `exit ${result.status}` };
26
+ }
27
+ export async function runUpdate(t, options = {}) {
28
+ const config = () => options.config ?? loadConfig();
29
+ const { platform = process.platform, checkCli = () => checkForUpdate(), checkRelay = () => checkForRelayUpdate(), installCli = (check, onAttempt) => performUpdate({
30
+ registry: check.registry,
31
+ sources: check.sources,
32
+ version: check.latest,
33
+ onAttempt,
34
+ }), installRelay = (check, onAttempt) => performRelayUpdate({
35
+ registry: check.registry,
36
+ sources: check.sources,
37
+ relayVersion: check.latest ?? '',
38
+ onAttempt,
39
+ }), isRunning = () => servicesRunning(config()), stop = () => stopAll(config()), start = () => startAll(config()), restartInstalled = restartWithInstalledRelease, stopBroker = stopAdminBroker, startBroker = async () => {
40
+ try {
41
+ startAdminBrokerTask(t);
42
+ }
43
+ catch {
44
+ // Denied or no task: the broker does not answer, which is reported next.
45
+ }
46
+ return waitForAdminBroker();
47
+ }, report = (line) => process.stdout.write(`${line}\n`), } = options;
48
+ report(t('update.checking'));
49
+ const [cli, relay] = await Promise.all([checkCli(), checkRelay()]);
50
+ const { current } = cli;
51
+ if (!cli.ok) {
52
+ report(t('update.errorNetworkDetail', { message: cli.message ?? '' }));
53
+ return { ok: false, current, updated: null, restarted: false, errorKey: cli.errorKey };
54
+ }
55
+ // A new herdr-remote brings the newest relay its range allows; only a
56
+ // current one needs the relay installed by itself.
57
+ const updated = cli.updateAvailable ? 'cli' : relay.ok && relay.updateAvailable ? 'relay' : null;
58
+ if (!updated) {
59
+ report(t('update.upToDate', { version: current }));
60
+ return { ok: true, current, updated, restarted: false };
61
+ }
62
+ const version = (updated === 'cli' ? cli.latest : relay.latest) ?? '';
63
+ const wasRunning = isRunning();
64
+ // Windows refuses to replace files a running process holds, as services
65
+ // started by older releases do. Elsewhere they keep serving until the restart.
66
+ const stopped = platform === 'win32' && wasRunning;
67
+ if (stopped) {
68
+ report(t('update.stoppingServices'));
69
+ stop();
70
+ }
71
+ // The admin broker stops too, so its task starts it on the new release.
72
+ const broker = platform === 'win32' ? await stopBroker() : 'unavailable';
73
+ const restoreBroker = async () => {
74
+ if (broker !== 'stopped')
75
+ return broker === 'busy' ? 'busy' : null;
76
+ const back = await startBroker();
77
+ report(t(back ? 'update.brokerRestarted' : 'update.brokerRestartFailed'));
78
+ return back ? 'restarted' : 'failed';
79
+ };
80
+ report(t(updated === 'cli' ? 'update.updating' : 'relayUpdate.updating', { version }));
81
+ const onAttempt = ({ attempt }) => {
82
+ if (attempt > 1)
83
+ report(t('update.updatingRetry', { version, attempt }));
84
+ };
85
+ const result = updated === 'cli' ? await installCli(cli, onAttempt) : await installRelay(relay, onAttempt);
86
+ if (!result.ok) {
87
+ // Nothing was replaced, so the old release starts as it was.
88
+ if (stopped)
89
+ start();
90
+ const adminBroker = await restoreBroker();
91
+ report(t(result.errorKey ?? 'update.errorFailed', { version, installed: result.installed ?? '' }));
92
+ if (result.summary)
93
+ report(t('update.errorFailedDetail', { message: result.summary }));
94
+ if (platform === 'win32' && /\b(EBUSY|EPERM)\b/.test(result.output ?? '')) {
95
+ report(t('update.errorBusy'));
96
+ }
97
+ const { errorKey, summary } = result;
98
+ return { ok: false, current, updated, restarted: false, adminBroker, errorKey, summary };
99
+ }
100
+ const installed = result.installed ?? version;
101
+ report(t(updated === 'cli' ? 'update.done' : 'relayUpdate.done', { version: installed }));
102
+ let ok = true;
103
+ let restarted = false;
104
+ if (!wasRunning) {
105
+ report(t('update.notRunning'));
106
+ }
107
+ else {
108
+ report(t('update.restarting'));
109
+ const restart = restartInstalled();
110
+ ok = restart.ok;
111
+ restarted = restart.ok;
112
+ report(restart.ok
113
+ ? t('services.restarted')
114
+ : t('update.restartFailed', { message: restart.message }));
115
+ }
116
+ if (broker === 'busy')
117
+ report(t('update.brokerBusy'));
118
+ const adminBroker = await restoreBroker();
119
+ return { ok, current, updated, installed, restarted, adminBroker };
120
+ }
package/herdr-plugin.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  id = "herdr.remote.web"
2
2
  name = "Herdr Remote Web"
3
- version = "0.2.27"
3
+ version = "0.2.28"
4
4
  min_herdr_version = "0.9.1"
5
5
  description = "Browser access to Herdr workspaces via local or self-hosted relay"
6
6
  platforms = ["linux", "macos", "windows"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote",
3
- "version": "0.2.27",
3
+ "version": "0.2.28",
4
4
  "description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
5
5
  "license": "MIT",
6
6
  "type": "module",