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 +4 -2
- package/README.zh-CN.md +3 -2
- package/dist/cli.js +20 -1
- package/dist/connector/admin-broker.js +87 -22
- package/dist/connector/update-report.js +2 -0
- package/dist/i18n/en.js +10 -0
- package/dist/i18n/zh.js +10 -0
- package/dist/keepalive/detached.js +3 -2
- package/dist/lifecycle.js +6 -2
- package/dist/node-pty-loader.js +80 -0
- package/dist/pty-session.js +2 -2
- package/dist/service.js +3 -1
- package/dist/supervisor.js +2 -2
- package/dist/update-command.js +120 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
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
|
-
|
|
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 {
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 = (
|
|
87
|
+
const finish = (reply) => {
|
|
76
88
|
if (settled)
|
|
77
89
|
return;
|
|
78
90
|
settled = true;
|
|
79
91
|
socket.destroy();
|
|
80
|
-
resolve(
|
|
92
|
+
resolve(reply);
|
|
81
93
|
};
|
|
82
|
-
socket.setTimeout(BROKER_PING_TIMEOUT_MS, () => finish(
|
|
94
|
+
socket.setTimeout(BROKER_PING_TIMEOUT_MS, () => finish(null));
|
|
83
95
|
socket.setEncoding('utf8');
|
|
84
|
-
socket.on('connect', () => sendAdminBrokerMessage(socket, { type
|
|
96
|
+
socket.on('connect', () => sendAdminBrokerMessage(socket, { type, token }));
|
|
85
97
|
socket.on('data', (chunk) => {
|
|
86
98
|
response += chunk;
|
|
87
|
-
|
|
88
|
-
|
|
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(
|
|
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
|
-
|
|
222
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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 = () =>
|
|
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
|
+
}
|
package/dist/pty-session.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
|
-
import
|
|
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 =
|
|
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:
|
|
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.
|
package/dist/supervisor.js
CHANGED
|
@@ -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,
|
|
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:
|
|
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