@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 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 --user, or Task Scheduler).
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
 
@@ -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.4';
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 linuxUnitPath() {
1076
+ function linuxUserUnitPath() {
1072
1077
  return join(homedir(), '.config', 'systemd', 'user', LINUX_SERVICE_UNIT);
1073
1078
  }
1074
1079
 
1075
- export function serviceKindForPlatform(platform = process.platform) {
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 'systemd --user';
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 kind = serviceKindForPlatform();
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
- spawnSync('systemctl', ['--user', 'stop', LINUX_SERVICE_UNIT], { stdio: 'ignore' });
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
- const quote = (value) => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
1177
- const unit = `[Unit]\nDescription=Jian Remote worker\nAfter=network-online.target\n\n[Service]\nExecStart=${quote(process.execPath)} ${quote(script)} run\nRestart=always\nRestartSec=3\n\n[Install]\nWantedBy=default.target\n`;
1178
- await writeFile(linuxUnitPath(), unit, { mode: 0o600 });
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('Could not install/start Jian Remote with systemd --user');
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: 'systemd --user', detail: LINUX_SERVICE_UNIT };
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)
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jian-ai/remote-control",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "Jian Remote Control worker — connect a computer to a Jian gateway over HTTPS",
5
5
  "type": "module",
6
6
  "bin": {