@jian-ai/remote-control 0.8.4 → 0.8.5
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 +2 -1
- package/bin/jian-remote.mjs +232 -15
- package/macos/computer-helper +0 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,7 +37,8 @@ jian-remote stop | start | restart
|
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
`start` / `restart` stop any previous worker before launching one process (LaunchAgent,
|
|
40
|
-
systemd
|
|
40
|
+
systemd `--user` or system unit, or Task Scheduler). On Linux headless/root hosts without a
|
|
41
|
+
user bus, `install` falls back to a system unit (or pass `install --system`).
|
|
41
42
|
|
|
42
43
|
## Configure
|
|
43
44
|
|
package/bin/jian-remote.mjs
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
import { spawn, spawnSync } from 'node:child_process';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import {
|
|
5
|
+
accessSync,
|
|
5
6
|
existsSync,
|
|
7
|
+
constants as fsConstants,
|
|
6
8
|
mkdtempSync,
|
|
7
9
|
readdirSync,
|
|
8
10
|
readFileSync,
|
|
@@ -22,7 +24,7 @@ import {
|
|
|
22
24
|
unlink,
|
|
23
25
|
writeFile,
|
|
24
26
|
} from 'node:fs/promises';
|
|
25
|
-
import { homedir, tmpdir } from 'node:os';
|
|
27
|
+
import { homedir, tmpdir, userInfo } from 'node:os';
|
|
26
28
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
27
29
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
28
30
|
|
|
@@ -35,6 +37,9 @@ const workerLockPath = join(configDir, 'worker.pid');
|
|
|
35
37
|
const updateStatePath = join(configDir, 'update-state.json');
|
|
36
38
|
const MACOS_LAUNCH_AGENT_LABEL = 'com.jian.remote';
|
|
37
39
|
const LINUX_SERVICE_UNIT = 'jian-remote.service';
|
|
40
|
+
/** Remembers whether Linux install used systemd --user or a system unit (headless/root VPS). */
|
|
41
|
+
const linuxServiceScopePath = join(configDir, 'linux-service-scope');
|
|
42
|
+
const LINUX_SYSTEM_UNIT_PATH = join('/etc/systemd/system', LINUX_SERVICE_UNIT);
|
|
38
43
|
const WINDOWS_TASK_NAME = 'Jian Remote';
|
|
39
44
|
/** Minimal PATH for launchd — app-identity Node copies otherwise inherit an empty env. */
|
|
40
45
|
export const LAUNCH_AGENT_PATH = '/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin';
|
|
@@ -64,7 +69,7 @@ export function parseCliInvocation(argv = args) {
|
|
|
64
69
|
* Stamped to the release tag on npm publish and when the gateway serves this script.
|
|
65
70
|
* Compatibility with the gateway requires the same major.minor (see versionsCompatible).
|
|
66
71
|
*/
|
|
67
|
-
export const PACKAGE_VERSION = '0.8.
|
|
72
|
+
export const PACKAGE_VERSION = '0.8.5';
|
|
68
73
|
|
|
69
74
|
/** Soft stdout/stderr caps while capturing a command (chars). Gateway schema matches these. */
|
|
70
75
|
const STDOUT_CAP = 400_000;
|
|
@@ -1068,13 +1073,174 @@ function macosPlistPath() {
|
|
|
1068
1073
|
return join(homedir(), 'Library', 'LaunchAgents', `${MACOS_LAUNCH_AGENT_LABEL}.plist`);
|
|
1069
1074
|
}
|
|
1070
1075
|
|
|
1071
|
-
function
|
|
1076
|
+
function linuxUserUnitPath() {
|
|
1072
1077
|
return join(homedir(), '.config', 'systemd', 'user', LINUX_SERVICE_UNIT);
|
|
1073
1078
|
}
|
|
1074
1079
|
|
|
1075
|
-
|
|
1080
|
+
/**
|
|
1081
|
+
* True when this process can talk to the per-user systemd bus.
|
|
1082
|
+
* Headless VPS / root SSH sessions often lack XDG_RUNTIME_DIR and fail with
|
|
1083
|
+
* "Failed to connect to bus: No medium found".
|
|
1084
|
+
*/
|
|
1085
|
+
export function linuxUserBusAvailable({
|
|
1086
|
+
env = process.env,
|
|
1087
|
+
run = (argv) => spawnSync('systemctl', ['--user', ...argv], { encoding: 'utf8' }),
|
|
1088
|
+
} = {}) {
|
|
1089
|
+
const runtimeDir = env.XDG_RUNTIME_DIR;
|
|
1090
|
+
if (!runtimeDir || typeof runtimeDir !== 'string') return false;
|
|
1091
|
+
try {
|
|
1092
|
+
if (!statSync(runtimeDir).isDirectory()) return false;
|
|
1093
|
+
} catch {
|
|
1094
|
+
return false;
|
|
1095
|
+
}
|
|
1096
|
+
const result = run(['show-environment']);
|
|
1097
|
+
if (result.error) return false;
|
|
1098
|
+
const stderr = `${result.stderr ?? ''}`;
|
|
1099
|
+
if (/Failed to connect to bus|No medium found|No such file or directory/i.test(stderr)) {
|
|
1100
|
+
return false;
|
|
1101
|
+
}
|
|
1102
|
+
return result.status === 0;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/** True when this process can install `/etc/systemd/system/jian-remote.service`. */
|
|
1106
|
+
export function canInstallLinuxSystemUnit({
|
|
1107
|
+
uid = typeof process.getuid === 'function' ? process.getuid() : undefined,
|
|
1108
|
+
systemDir = '/etc/systemd/system',
|
|
1109
|
+
access = (path, mode) => accessSync(path, mode),
|
|
1110
|
+
} = {}) {
|
|
1111
|
+
if (uid === 0) return true;
|
|
1112
|
+
try {
|
|
1113
|
+
access(systemDir, fsConstants.W_OK);
|
|
1114
|
+
return true;
|
|
1115
|
+
} catch {
|
|
1116
|
+
return false;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
export function linuxServiceKind(scope = 'user') {
|
|
1121
|
+
return scope === 'system' ? 'systemd (system)' : 'systemd --user';
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Pick systemd --user vs system unit. Prefer --user when the bus works; fall back to a
|
|
1126
|
+
* system unit on headless/root hosts; `--system` forces the system unit.
|
|
1127
|
+
*/
|
|
1128
|
+
export function chooseLinuxServiceScope({
|
|
1129
|
+
preferSystem = false,
|
|
1130
|
+
env = process.env,
|
|
1131
|
+
userBusAvailable = () => linuxUserBusAvailable({ env }),
|
|
1132
|
+
canSystem = () => canInstallLinuxSystemUnit(),
|
|
1133
|
+
} = {}) {
|
|
1134
|
+
if (preferSystem) {
|
|
1135
|
+
if (!canSystem()) {
|
|
1136
|
+
throw new Error(
|
|
1137
|
+
'install --system needs write access to /etc/systemd/system (run as root, or use sudo).',
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
return 'system';
|
|
1141
|
+
}
|
|
1142
|
+
if (userBusAvailable()) return 'user';
|
|
1143
|
+
if (canSystem()) return 'system';
|
|
1144
|
+
throw new Error(
|
|
1145
|
+
[
|
|
1146
|
+
'Could not reach systemd --user (no user bus). This is common on a headless VPS or root SSH session',
|
|
1147
|
+
'("Failed to connect to bus: No medium found" / missing XDG_RUNTIME_DIR).',
|
|
1148
|
+
'',
|
|
1149
|
+
'Fix options:',
|
|
1150
|
+
' 1. Prefer a normal user + lingering, then reinstall:',
|
|
1151
|
+
' sudo loginctl enable-linger USERNAME',
|
|
1152
|
+
' # as USERNAME:',
|
|
1153
|
+
' export XDG_RUNTIME_DIR=/run/user/$(id -u)',
|
|
1154
|
+
' jian-remote install --gateway … --machine … --token …',
|
|
1155
|
+
' 2. Install a system unit (as root):',
|
|
1156
|
+
' jian-remote install --system --gateway … --machine … --token …',
|
|
1157
|
+
' 3. Foreground smoke test (config may already be on disk from a failed install):',
|
|
1158
|
+
' node ~/.config/jian-remote/jian-remote.mjs run',
|
|
1159
|
+
].join('\n'),
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
async function writeLinuxServiceScope(scope) {
|
|
1164
|
+
await mkdir(configDir, { recursive: true, mode: 0o700 });
|
|
1165
|
+
await writeFile(linuxServiceScopePath, `${scope}\n`, { mode: 0o600 });
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
async function readLinuxServiceScope() {
|
|
1169
|
+
try {
|
|
1170
|
+
const text = (await readFile(linuxServiceScopePath, 'utf8')).trim();
|
|
1171
|
+
if (text === 'system' || text === 'user') return text;
|
|
1172
|
+
} catch {
|
|
1173
|
+
// absent or unreadable — fall through to heuristics
|
|
1174
|
+
}
|
|
1175
|
+
if (existsSync(LINUX_SYSTEM_UNIT_PATH)) return 'system';
|
|
1176
|
+
return 'user';
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* Fresh `install` probes the bus (and honors `--system`). Later start/restart/update/configure
|
|
1181
|
+
* keep the saved scope so a system-unit VPS install does not flip back if a user bus appears.
|
|
1182
|
+
*/
|
|
1183
|
+
async function resolveLinuxInstallScope() {
|
|
1184
|
+
const preferSystem = args.includes('--system');
|
|
1185
|
+
if (preferSystem) return chooseLinuxServiceScope({ preferSystem: true });
|
|
1186
|
+
const command = parseCliInvocation(args).command;
|
|
1187
|
+
if (command && command !== 'install') {
|
|
1188
|
+
try {
|
|
1189
|
+
const saved = (await readFile(linuxServiceScopePath, 'utf8')).trim();
|
|
1190
|
+
if (saved === 'system') {
|
|
1191
|
+
if (canInstallLinuxSystemUnit()) return 'system';
|
|
1192
|
+
} else if (saved === 'user' && linuxUserBusAvailable()) {
|
|
1193
|
+
return 'user';
|
|
1194
|
+
}
|
|
1195
|
+
} catch {
|
|
1196
|
+
// choose below
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
return chooseLinuxServiceScope({ preferSystem: false });
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
export function buildLinuxServiceUnit({
|
|
1203
|
+
script,
|
|
1204
|
+
scope,
|
|
1205
|
+
home = homedir(),
|
|
1206
|
+
nodePath = process.execPath,
|
|
1207
|
+
uid = typeof process.getuid === 'function' ? process.getuid() : 0,
|
|
1208
|
+
username,
|
|
1209
|
+
} = {}) {
|
|
1210
|
+
const quote = (value) => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
1211
|
+
const wantedBy = scope === 'system' ? 'multi-user.target' : 'default.target';
|
|
1212
|
+
const lines = [
|
|
1213
|
+
'[Unit]',
|
|
1214
|
+
'Description=Jian Remote worker',
|
|
1215
|
+
'After=network-online.target',
|
|
1216
|
+
'Wants=network-online.target',
|
|
1217
|
+
'',
|
|
1218
|
+
'[Service]',
|
|
1219
|
+
`ExecStart=${quote(nodePath)} ${quote(script)} run`,
|
|
1220
|
+
'Restart=always',
|
|
1221
|
+
'RestartSec=3',
|
|
1222
|
+
// System units do not inherit the installing shell's HOME; pin it so config stays findable.
|
|
1223
|
+
`Environment=${quote(`HOME=${home}`)}`,
|
|
1224
|
+
`WorkingDirectory=${quote(home)}`,
|
|
1225
|
+
];
|
|
1226
|
+
if (scope === 'system' && uid !== 0) {
|
|
1227
|
+
let name = username;
|
|
1228
|
+
if (!name) {
|
|
1229
|
+
try {
|
|
1230
|
+
name = userInfo().username;
|
|
1231
|
+
} catch {
|
|
1232
|
+
name = undefined;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
if (name) lines.push(`User=${name}`);
|
|
1236
|
+
}
|
|
1237
|
+
lines.push('', '[Install]', `WantedBy=${wantedBy}`, '');
|
|
1238
|
+
return lines.join('\n');
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
export function serviceKindForPlatform(platform = process.platform, linuxScope = 'user') {
|
|
1076
1242
|
if (platform === 'darwin') return 'LaunchAgent';
|
|
1077
|
-
if (platform === 'linux') return
|
|
1243
|
+
if (platform === 'linux') return linuxServiceKind(linuxScope);
|
|
1078
1244
|
if (platform === 'win32') return 'Task Scheduler';
|
|
1079
1245
|
return 'none';
|
|
1080
1246
|
}
|
|
@@ -1084,7 +1250,8 @@ export function serviceKindForPlatform(platform = process.platform) {
|
|
|
1084
1250
|
* Clear success/failure for the `jian-remote stop` CLI.
|
|
1085
1251
|
*/
|
|
1086
1252
|
async function stopService() {
|
|
1087
|
-
const
|
|
1253
|
+
const linuxScope = process.platform === 'linux' ? await readLinuxServiceScope() : 'user';
|
|
1254
|
+
const kind = serviceKindForPlatform(process.platform, linuxScope);
|
|
1088
1255
|
if (process.platform === 'darwin') {
|
|
1089
1256
|
const plistPath = macosPlistPath();
|
|
1090
1257
|
const domain = `gui/${process.getuid()}`;
|
|
@@ -1093,7 +1260,11 @@ async function stopService() {
|
|
|
1093
1260
|
stdio: 'ignore',
|
|
1094
1261
|
});
|
|
1095
1262
|
} else if (process.platform === 'linux') {
|
|
1096
|
-
|
|
1263
|
+
if (linuxScope === 'system') {
|
|
1264
|
+
spawnSync('systemctl', ['stop', LINUX_SERVICE_UNIT], { stdio: 'ignore' });
|
|
1265
|
+
} else {
|
|
1266
|
+
spawnSync('systemctl', ['--user', 'stop', LINUX_SERVICE_UNIT], { stdio: 'ignore' });
|
|
1267
|
+
}
|
|
1097
1268
|
} else if (process.platform === 'win32') {
|
|
1098
1269
|
spawnSync('schtasks.exe', ['/End', '/TN', WINDOWS_TASK_NAME], {
|
|
1099
1270
|
stdio: 'ignore',
|
|
@@ -1171,17 +1342,62 @@ async function startService(script = installedScript) {
|
|
|
1171
1342
|
};
|
|
1172
1343
|
}
|
|
1173
1344
|
if (process.platform === 'linux') {
|
|
1345
|
+
const scope = await resolveLinuxInstallScope();
|
|
1346
|
+
const unitBody = buildLinuxServiceUnit({ script, scope });
|
|
1347
|
+
if (scope === 'system') {
|
|
1348
|
+
// Drop a leftover user unit so stop/start do not fight two managers.
|
|
1349
|
+
spawnSync('systemctl', ['--user', 'disable', '--now', LINUX_SERVICE_UNIT], {
|
|
1350
|
+
stdio: 'ignore',
|
|
1351
|
+
});
|
|
1352
|
+
try {
|
|
1353
|
+
await unlink(linuxUserUnitPath());
|
|
1354
|
+
} catch {
|
|
1355
|
+
// absent
|
|
1356
|
+
}
|
|
1357
|
+
await writeFile(LINUX_SYSTEM_UNIT_PATH, unitBody, { mode: 0o644 });
|
|
1358
|
+
await writeLinuxServiceScope('system');
|
|
1359
|
+
for (const command of [['daemon-reload'], ['enable', '--now', LINUX_SERVICE_UNIT]]) {
|
|
1360
|
+
const result = spawnSync('systemctl', command, { stdio: 'inherit' });
|
|
1361
|
+
if (result.status !== 0) {
|
|
1362
|
+
throw new Error('Could not install/start Jian Remote with systemd (system unit)');
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
if (!args.includes('--system') && parseCliInvocation(args).command === 'install') {
|
|
1366
|
+
printInfo(
|
|
1367
|
+
'systemd --user bus unavailable; installed a system unit instead (headless/root). Prefer a normal user + loginctl enable-linger when you can.',
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
return { service: linuxServiceKind('system'), detail: LINUX_SYSTEM_UNIT_PATH };
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1174
1373
|
const unitDir = join(homedir(), '.config', 'systemd', 'user');
|
|
1175
1374
|
await mkdir(unitDir, { recursive: true, mode: 0o700 });
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1375
|
+
await writeFile(linuxUserUnitPath(), unitBody, { mode: 0o600 });
|
|
1376
|
+
await writeLinuxServiceScope('user');
|
|
1377
|
+
// Best-effort: clear a previous system install if this user can.
|
|
1378
|
+
if (existsSync(LINUX_SYSTEM_UNIT_PATH) && canInstallLinuxSystemUnit()) {
|
|
1379
|
+
spawnSync('systemctl', ['disable', '--now', LINUX_SERVICE_UNIT], { stdio: 'ignore' });
|
|
1380
|
+
try {
|
|
1381
|
+
await unlink(LINUX_SYSTEM_UNIT_PATH);
|
|
1382
|
+
spawnSync('systemctl', ['daemon-reload'], { stdio: 'ignore' });
|
|
1383
|
+
} catch {
|
|
1384
|
+
// leave system unit; user scope marker wins for this CLI
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1179
1387
|
for (const command of [['daemon-reload'], ['enable', '--now', LINUX_SERVICE_UNIT]]) {
|
|
1180
1388
|
const result = spawnSync('systemctl', ['--user', ...command], { stdio: 'inherit' });
|
|
1181
|
-
if (result.status !== 0)
|
|
1182
|
-
throw new Error(
|
|
1389
|
+
if (result.status !== 0) {
|
|
1390
|
+
throw new Error(
|
|
1391
|
+
[
|
|
1392
|
+
'Could not install/start Jian Remote with systemd --user.',
|
|
1393
|
+
'If you see "Failed to connect to bus: No medium found", the user bus is missing',
|
|
1394
|
+
'(common on headless VPS / root SSH). Retry with: jian-remote install --system …',
|
|
1395
|
+
'or enable lingering: sudo loginctl enable-linger "$(whoami)" && export XDG_RUNTIME_DIR=/run/user/$(id -u)',
|
|
1396
|
+
].join(' '),
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1183
1399
|
}
|
|
1184
|
-
return { service: '
|
|
1400
|
+
return { service: linuxServiceKind('user'), detail: LINUX_SERVICE_UNIT };
|
|
1185
1401
|
}
|
|
1186
1402
|
if (process.platform === 'win32') {
|
|
1187
1403
|
const action = `"${process.execPath}" "${script}" run`;
|
|
@@ -1256,7 +1472,7 @@ async function install() {
|
|
|
1256
1472
|
const gatewayBase = parseGatewayBase(gateway);
|
|
1257
1473
|
if (!gatewayBase || !MACHINE_ID.test(machineId ?? '') || !token?.trim()) {
|
|
1258
1474
|
throw new Error(
|
|
1259
|
-
'Usage: jian-remote install --gateway https://... --machine UUID (--token VALUE | --token-prompt | --token-stdin)',
|
|
1475
|
+
'Usage: jian-remote install [--system] --gateway https://... --machine UUID (--token VALUE | --token-prompt | --token-stdin)',
|
|
1260
1476
|
);
|
|
1261
1477
|
}
|
|
1262
1478
|
const gatewayVersion = await assertCompatibleWithGateway(gatewayBase);
|
|
@@ -2086,7 +2302,7 @@ if (isCliEntry()) {
|
|
|
2086
2302
|
const pkg = paint('(@jian-ai/remote-control)', ANSI.dim, color);
|
|
2087
2303
|
console.log(`${title} worker ${pkg}
|
|
2088
2304
|
version | --version | -V
|
|
2089
|
-
install --gateway HTTPS_URL --machine UUID (--token VALUE | --token-prompt | --token-stdin)
|
|
2305
|
+
install [--system] --gateway HTTPS_URL --machine UUID (--token VALUE | --token-prompt | --token-stdin)
|
|
2090
2306
|
configure [--gateway HTTPS_URL] [--machine UUID] (--token VALUE | --token-prompt | --token-stdin)
|
|
2091
2307
|
update [--version X.Y.Z|latest]
|
|
2092
2308
|
stop | start | restart
|
|
@@ -2094,6 +2310,7 @@ if (isCliEntry()) {
|
|
|
2094
2310
|
|
|
2095
2311
|
Install: npm install -g @jian-ai/remote-control
|
|
2096
2312
|
Or: npx @jian-ai/remote-control@latest install --gateway … --machine … --token …
|
|
2313
|
+
(Linux headless/root VPS: add --system, or use a normal user + loginctl enable-linger)
|
|
2097
2314
|
Configure: jian-remote configure --token … (or --gateway / --machine; restarts the service)
|
|
2098
2315
|
Update: npx @jian-ai/remote-control@latest update
|
|
2099
2316
|
(upgrades the worker binary; reads ~/.config/jian-remote)
|
package/macos/computer-helper
CHANGED
|
Binary file
|