@jian-ai/remote-control 0.8.4 → 1.0.0

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
@@ -5,25 +5,19 @@ over outbound HTTPS polling.
5
5
 
6
6
  ## Install
7
7
 
8
- ```sh
9
- npm install -g @jian-ai/remote-control
10
- jian-remote install --gateway https://gateway.example --machine MACHINE_UUID --token WORKER_TOKEN
11
- ```
12
-
13
- Or without a global install:
8
+ The Remote Control page of the panel shows this command with your values filled in:
14
9
 
15
10
  ```sh
16
- npx @jian-ai/remote-control@latest install \
17
- --gateway https://gateway.example \
18
- --machine MACHINE_UUID \
19
- --token WORKER_TOKEN
11
+ npm install -g @jian-ai/remote-control && jian-remote install \
12
+ --gateway https://gateway.example --machine MACHINE_UUID --token WORKER_TOKEN
20
13
  ```
21
14
 
22
15
  ## Update
23
16
 
17
+ The worker keeps itself on the gateway's version. To update by hand:
18
+
24
19
  ```sh
25
- npx @jian-ai/remote-control@latest update
26
- # or: jian-remote update [--version 0.6.0]
20
+ jian-remote update [--version 0.6.0]
27
21
  ```
28
22
 
29
23
  Reads `~/.config/jian-remote` (no token, machine id, or gateway flags), installs a compatible
@@ -37,7 +31,8 @@ jian-remote stop | start | restart
37
31
  ```
38
32
 
39
33
  `start` / `restart` stop any previous worker before launching one process (LaunchAgent,
40
- systemd --user, or Task Scheduler).
34
+ systemd `--user` or system unit, or Task Scheduler). On Linux headless/root hosts without a
35
+ user bus, `install` falls back to a system unit (or pass `install --system`).
41
36
 
42
37
  ## Configure
43
38
 
@@ -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 = '1.0.0';
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);
@@ -1959,6 +2175,9 @@ async function postResult(base, headers, result) {
1959
2175
  async function run() {
1960
2176
  await acquireWorkerLock();
1961
2177
  let stopMacIdle = () => {};
2178
+ // Set once the gateway is known: tells it this worker is stopping, so the panel shows the
2179
+ // machine offline at once instead of when its last poll grows old.
2180
+ let sayGoodbye = async () => {};
1962
2181
  const shutdown = () => {
1963
2182
  try {
1964
2183
  stopMacIdle();
@@ -1974,8 +2193,10 @@ async function run() {
1974
2193
  };
1975
2194
  for (const signal of ['SIGINT', 'SIGTERM']) {
1976
2195
  process.once(signal, () => {
1977
- shutdown();
1978
- process.exit(0);
2196
+ void sayGoodbye().finally(() => {
2197
+ shutdown();
2198
+ process.exit(0);
2199
+ });
1979
2200
  });
1980
2201
  }
1981
2202
  process.once('exit', shutdown);
@@ -2008,6 +2229,17 @@ async function run() {
2008
2229
  ...(capabilities.length ? { 'x-jian-remote-capabilities': capabilities.join(',') } : {}),
2009
2230
  });
2010
2231
  const base = `${config.gateway}/v1/remote/worker/${config.machineId}`;
2232
+ // Best effort and brief: a stop must not hang on an unreachable gateway.
2233
+ sayGoodbye = () =>
2234
+ fetch(`${base}/disconnect`, {
2235
+ method: 'POST',
2236
+ headers: pollHeaders(),
2237
+ redirect: 'error',
2238
+ signal: AbortSignal.timeout(2000),
2239
+ }).then(
2240
+ () => {},
2241
+ () => {},
2242
+ );
2011
2243
  // Match the gateway worker (including major.minor skew) before polling so fleets do not
2012
2244
  // crash-loop on refuse without attempting a self-update first.
2013
2245
  const gatewayVersion = await reconcileWorkerWithGateway(config, base, pollHeaders());
@@ -2086,7 +2318,7 @@ if (isCliEntry()) {
2086
2318
  const pkg = paint('(@jian-ai/remote-control)', ANSI.dim, color);
2087
2319
  console.log(`${title} worker ${pkg}
2088
2320
  version | --version | -V
2089
- install --gateway HTTPS_URL --machine UUID (--token VALUE | --token-prompt | --token-stdin)
2321
+ install [--system] --gateway HTTPS_URL --machine UUID (--token VALUE | --token-prompt | --token-stdin)
2090
2322
  configure [--gateway HTTPS_URL] [--machine UUID] (--token VALUE | --token-prompt | --token-stdin)
2091
2323
  update [--version X.Y.Z|latest]
2092
2324
  stop | start | restart
@@ -2094,6 +2326,7 @@ if (isCliEntry()) {
2094
2326
 
2095
2327
  Install: npm install -g @jian-ai/remote-control
2096
2328
  Or: npx @jian-ai/remote-control@latest install --gateway … --machine … --token …
2329
+ (Linux headless/root VPS: add --system, or use a normal user + loginctl enable-linger)
2097
2330
  Configure: jian-remote configure --token … (or --gateway / --machine; restarts the service)
2098
2331
  Update: npx @jian-ai/remote-control@latest update
2099
2332
  (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": "1.0.0",
4
4
  "description": "Jian Remote Control worker — connect a computer to a Jian gateway over HTTPS",
5
5
  "type": "module",
6
6
  "bin": {