@jian-ai/remote-control 0.8.1 → 0.8.3
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 +22 -4
- package/bin/computer-linux.mjs +537 -0
- package/bin/computer-macos.mjs +205 -62
- package/bin/computer-shared.mjs +61 -0
- package/bin/computer-windows.mjs +516 -0
- package/bin/computer.mjs +63 -0
- package/bin/jian-remote.mjs +819 -105
- package/linux/computer-helper.py +695 -0
- package/macos/computer-helper +0 -0
- package/macos/computer-helper.swift +416 -141
- package/macos/jian-pointer.png +0 -0
- package/package.json +3 -1
- package/windows/computer-helper.ps1 +501 -0
package/bin/jian-remote.mjs
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn, spawnSync } from 'node:child_process';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
statSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from 'node:fs';
|
|
13
|
+
import { chmod, copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
6
14
|
import { homedir, tmpdir } from 'node:os';
|
|
7
15
|
import { dirname, join, resolve } from 'node:path';
|
|
8
16
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
@@ -11,17 +19,41 @@ const configDir = join(homedir(), '.config', 'jian-remote');
|
|
|
11
19
|
const configPath = join(configDir, 'config.json');
|
|
12
20
|
/** Stable path the OS service runs — not the npx cache or a transient install location. */
|
|
13
21
|
const installedScript = join(configDir, 'jian-remote.mjs');
|
|
22
|
+
/** Exclusive run lock — prevents KeepAlive / CLI races from leaving two pollers alive. */
|
|
23
|
+
const workerLockPath = join(configDir, 'worker.pid');
|
|
24
|
+
const updateStatePath = join(configDir, 'update-state.json');
|
|
25
|
+
const MACOS_LAUNCH_AGENT_LABEL = 'com.jian.remote';
|
|
26
|
+
const LINUX_SERVICE_UNIT = 'jian-remote.service';
|
|
27
|
+
const WINDOWS_TASK_NAME = 'Jian Remote';
|
|
28
|
+
/** Minimal PATH for launchd — app-identity Node copies otherwise inherit an empty env. */
|
|
29
|
+
export const LAUNCH_AGENT_PATH = '/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin';
|
|
30
|
+
/** KeepAlive floor so a bad update cannot spin-restart every second. */
|
|
31
|
+
export const LAUNCH_AGENT_THROTTLE_INTERVAL_SEC = 10;
|
|
32
|
+
|
|
14
33
|
const args = process.argv.slice(2);
|
|
15
34
|
const option = (name) => {
|
|
16
35
|
const index = args.indexOf(name);
|
|
17
36
|
return index < 0 ? undefined : args[index + 1];
|
|
18
37
|
};
|
|
19
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Parse argv for the top-level command and version flags (`version`, `--version`, `-V`).
|
|
41
|
+
* Version flags win over a subcommand so `jian-remote --version update` still prints the version.
|
|
42
|
+
*/
|
|
43
|
+
export function parseCliInvocation(argv = args) {
|
|
44
|
+
const list = Array.isArray(argv) ? argv : [];
|
|
45
|
+
if (list.includes('--version') || list.includes('-V') || list[0] === 'version') {
|
|
46
|
+
return { command: 'version', versionRequested: true };
|
|
47
|
+
}
|
|
48
|
+
const command = list[0] && !list[0].startsWith('-') ? list[0] : undefined;
|
|
49
|
+
return { command, versionRequested: false };
|
|
50
|
+
}
|
|
51
|
+
|
|
20
52
|
/**
|
|
21
53
|
* Stamped to the release tag on npm publish and when the gateway serves this script.
|
|
22
54
|
* Compatibility with the gateway requires the same major.minor (see versionsCompatible).
|
|
23
55
|
*/
|
|
24
|
-
export const PACKAGE_VERSION = '0.8.
|
|
56
|
+
export const PACKAGE_VERSION = '0.8.3';
|
|
25
57
|
|
|
26
58
|
/** Soft stdout/stderr caps while capturing a command (chars). Gateway schema matches these. */
|
|
27
59
|
const STDOUT_CAP = 400_000;
|
|
@@ -135,7 +167,23 @@ export function buildMacosAppInfoPlist() {
|
|
|
135
167
|
`;
|
|
136
168
|
}
|
|
137
169
|
|
|
138
|
-
export function buildLaunchAgentPlist({
|
|
170
|
+
export function buildLaunchAgentPlist({
|
|
171
|
+
label,
|
|
172
|
+
executable,
|
|
173
|
+
script,
|
|
174
|
+
logPath,
|
|
175
|
+
pathEnv = LAUNCH_AGENT_PATH,
|
|
176
|
+
throttleIntervalSec = LAUNCH_AGENT_THROTTLE_INTERVAL_SEC,
|
|
177
|
+
home = homedir(),
|
|
178
|
+
tmpDir = process.env.TMPDIR || '/tmp',
|
|
179
|
+
}) {
|
|
180
|
+
// ProcessType=Interactive opts the worker out of App Nap. Without it, macOS can freeze the
|
|
181
|
+
// Node poll loop while the screen is locked or idle; lastSeenAt ages out and the gateway
|
|
182
|
+
// shows Offline even though the process is still "running".
|
|
183
|
+
// Explicit PATH + HOME: launchd + the Jian Remote.app Node copy otherwise inherit a sparse
|
|
184
|
+
// env. Missing HOME makes os.homedir() / shell cwd wrong; without /bin on PATH, child tools
|
|
185
|
+
// fail. Invalid cwd surfaces as the misleading "spawn /bin/sh ENOENT" from libuv.
|
|
186
|
+
// ThrottleInterval: after an update exit, KeepAlive must not immediately fight a CLI restart.
|
|
139
187
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
140
188
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
141
189
|
<plist version="1.0"><dict>
|
|
@@ -148,8 +196,17 @@ export function buildLaunchAgentPlist({ label, executable, script, logPath }) {
|
|
|
148
196
|
<string>${xmlEscape(script)}</string>
|
|
149
197
|
<string>run</string>
|
|
150
198
|
</array>
|
|
199
|
+
<key>WorkingDirectory</key><string>${xmlEscape(home)}</string>
|
|
200
|
+
<key>EnvironmentVariables</key>
|
|
201
|
+
<dict>
|
|
202
|
+
<key>PATH</key><string>${xmlEscape(pathEnv)}</string>
|
|
203
|
+
<key>HOME</key><string>${xmlEscape(home)}</string>
|
|
204
|
+
<key>TMPDIR</key><string>${xmlEscape(tmpDir)}</string>
|
|
205
|
+
</dict>
|
|
206
|
+
<key>ProcessType</key><string>Interactive</string>
|
|
151
207
|
<key>RunAtLoad</key><true/>
|
|
152
208
|
<key>KeepAlive</key><true/>
|
|
209
|
+
<key>ThrottleInterval</key><integer>${Number(throttleIntervalSec) || 10}</integer>
|
|
153
210
|
<key>StandardOutPath</key><string>${xmlEscape(logPath)}</string>
|
|
154
211
|
<key>StandardErrorPath</key><string>${xmlEscape(logPath)}</string>
|
|
155
212
|
</dict></plist>
|
|
@@ -239,7 +296,7 @@ export function versionsCompatible(localVersion, gatewayVersion) {
|
|
|
239
296
|
local: local.raw,
|
|
240
297
|
gateway: gateway.raw,
|
|
241
298
|
required: `${gateway.major}.${gateway.minor}`,
|
|
242
|
-
message: `This remote-control ${local.raw} is incompatible with gateway ${gateway.raw}. Jian requires the same major.minor (${gateway.major}.${gateway.minor}).
|
|
299
|
+
message: `This remote-control ${local.raw} is incompatible with gateway ${gateway.raw}. Jian requires the same major.minor (${gateway.major}.${gateway.minor}). Connected workers try to self-update to the gateway version automatically; if that fails, run: npx @jian-ai/remote-control@${gateway.raw} update`,
|
|
243
300
|
};
|
|
244
301
|
}
|
|
245
302
|
|
|
@@ -248,6 +305,50 @@ export function compatibilityError(localVersion, gatewayVersion) {
|
|
|
248
305
|
return result.ok ? null : result.message;
|
|
249
306
|
}
|
|
250
307
|
|
|
308
|
+
/**
|
|
309
|
+
* True when the worker should self-update so its PACKAGE_VERSION matches the gateway release.
|
|
310
|
+
* Covers patch bumps and major.minor skew (fleet upgrade to the gateway’s version). Dev /
|
|
311
|
+
* unversioned gateways never force a match.
|
|
312
|
+
*/
|
|
313
|
+
export function shouldMatchGatewayVersion(localVersion, gatewayVersion) {
|
|
314
|
+
if (isDevVersion(gatewayVersion)) return false;
|
|
315
|
+
const gateway = parseRelease(gatewayVersion);
|
|
316
|
+
if (!gateway) return false;
|
|
317
|
+
if (isDevVersion(localVersion)) return true;
|
|
318
|
+
const local = parseRelease(localVersion);
|
|
319
|
+
if (!local) return true;
|
|
320
|
+
return local.raw !== gateway.raw;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Soft ceiling so a failed fleet update does not KeepAlive-spin every few seconds. */
|
|
324
|
+
export function nextUpdateBackoffMs(failureCount) {
|
|
325
|
+
const n = Math.max(0, Number(failureCount) || 0);
|
|
326
|
+
// 5s, 10s, … then cap at 30 minutes (2^9 * 5s = 2560s > 30m).
|
|
327
|
+
return Math.min(30 * 60_000, 5_000 * 2 ** Math.min(n, 9));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Reject garbage / HTML / truncated downloads before replacing ~/.config/jian-remote/jian-remote.mjs.
|
|
332
|
+
* Credentials and config.json are never touched by the updater.
|
|
333
|
+
*/
|
|
334
|
+
export function validateWorkerScriptBytes(bytes) {
|
|
335
|
+
if (!Buffer.isBuffer(bytes) && typeof bytes !== 'string') {
|
|
336
|
+
return { ok: false, message: 'Worker update was empty or unreadable.' };
|
|
337
|
+
}
|
|
338
|
+
const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
339
|
+
if (!buffer.length || buffer.length > 1_000_000) {
|
|
340
|
+
return { ok: false, message: 'Invalid worker update size.' };
|
|
341
|
+
}
|
|
342
|
+
const source = buffer.toString('utf8');
|
|
343
|
+
if (!source.includes('Jian Remote') || !/export const PACKAGE_VERSION = '[^']+'/.test(source)) {
|
|
344
|
+
return { ok: false, message: 'Worker update did not look like a Jian Remote script.' };
|
|
345
|
+
}
|
|
346
|
+
if (!source.includes('updateIfNeeded') || !source.includes('async function run(')) {
|
|
347
|
+
return { ok: false, message: 'Worker update was missing required entrypoints.' };
|
|
348
|
+
}
|
|
349
|
+
return { ok: true, version: readEmbeddedVersion(source) };
|
|
350
|
+
}
|
|
351
|
+
|
|
251
352
|
export function cappedAppend(current, chunk, cap = STDOUT_CAP) {
|
|
252
353
|
return (current + chunk.toString('utf8')).slice(0, cap);
|
|
253
354
|
}
|
|
@@ -474,6 +575,35 @@ function fetchNpmWorkerScript(version) {
|
|
|
474
575
|
}
|
|
475
576
|
}
|
|
476
577
|
|
|
578
|
+
/**
|
|
579
|
+
* Copy computer-use companions next to the installed worker. Never overwrites jian-remote.mjs
|
|
580
|
+
* (auto-update must not clobber a gateway-stamped script with an older npm copy).
|
|
581
|
+
*/
|
|
582
|
+
async function materializeCompanionModules() {
|
|
583
|
+
try {
|
|
584
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
585
|
+
const companions = [
|
|
586
|
+
'computer.mjs',
|
|
587
|
+
'computer-shared.mjs',
|
|
588
|
+
'computer-macos.mjs',
|
|
589
|
+
'computer-linux.mjs',
|
|
590
|
+
'computer-windows.mjs',
|
|
591
|
+
];
|
|
592
|
+
for (const name of companions) {
|
|
593
|
+
const src = join(here, name);
|
|
594
|
+
const dest = join(configDir, name);
|
|
595
|
+
if (existsSync(src) && src !== dest) {
|
|
596
|
+
await copyFile(src, dest);
|
|
597
|
+
if (process.platform !== 'win32') await chmod(dest, 0o700);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const { materializeComputerHelperSources } = await import('./computer.mjs');
|
|
601
|
+
await materializeComputerHelperSources(configDir);
|
|
602
|
+
} catch {
|
|
603
|
+
// Install still succeeds; computer-use probe will explain if the helper is missing.
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
477
607
|
/** Copy a worker script into ~/.config so npx and global installs share one service path. */
|
|
478
608
|
async function materializeWorkerScript(sourceBytes) {
|
|
479
609
|
await mkdir(configDir, { recursive: true, mode: 0o700 });
|
|
@@ -486,41 +616,201 @@ async function materializeWorkerScript(sourceBytes) {
|
|
|
486
616
|
if (source !== installedScript) await copyFile(source, installedScript);
|
|
487
617
|
}
|
|
488
618
|
if (process.platform !== 'win32') await chmod(installedScript, 0o700);
|
|
489
|
-
|
|
619
|
+
await materializeCompanionModules();
|
|
620
|
+
return installedScript;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** True when `pid` is a live process (best-effort; false on ESRCH / invalid). */
|
|
624
|
+
export function isPidAlive(pid) {
|
|
625
|
+
const n = Number(pid);
|
|
626
|
+
if (!Number.isInteger(n) || n <= 0) return false;
|
|
490
627
|
try {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
628
|
+
process.kill(n, 0);
|
|
629
|
+
return true;
|
|
630
|
+
} catch {
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export function readWorkerLockPid(lockPath = workerLockPath) {
|
|
636
|
+
try {
|
|
637
|
+
const raw = readFileSync(lockPath, 'utf8').trim();
|
|
638
|
+
const pid = Number(raw);
|
|
639
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
640
|
+
} catch {
|
|
641
|
+
return undefined;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function sleepMs(ms) {
|
|
646
|
+
const sab = new SharedArrayBuffer(4);
|
|
647
|
+
const ia = new Int32Array(sab);
|
|
648
|
+
Atomics.wait(ia, 0, 0, Math.max(0, Number(ms) || 0));
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Stop a stray worker PID (SIGTERM → wait → SIGKILL). Used by stop/restart and the run lock.
|
|
653
|
+
*/
|
|
654
|
+
export function terminateWorkerPid(
|
|
655
|
+
pid,
|
|
656
|
+
{ waitMs = 8_000, kill = process.kill.bind(process) } = {},
|
|
657
|
+
) {
|
|
658
|
+
if (!isPidAlive(pid)) return { stopped: true, alreadyDead: true };
|
|
659
|
+
try {
|
|
660
|
+
kill(pid, 'SIGTERM');
|
|
661
|
+
} catch {
|
|
662
|
+
return { stopped: !isPidAlive(pid) };
|
|
663
|
+
}
|
|
664
|
+
const deadline = Date.now() + waitMs;
|
|
665
|
+
while (Date.now() < deadline && isPidAlive(pid)) sleepMs(100);
|
|
666
|
+
if (isPidAlive(pid)) {
|
|
667
|
+
try {
|
|
668
|
+
kill(pid, 'SIGKILL');
|
|
669
|
+
} catch {
|
|
670
|
+
// ESRCH — already gone.
|
|
671
|
+
}
|
|
672
|
+
sleepMs(100);
|
|
673
|
+
}
|
|
674
|
+
return { stopped: !isPidAlive(pid) };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Claim the exclusive worker lock. If another live PID holds it, terminate that process first
|
|
679
|
+
* so KeepAlive / CLI restarts never leave two pollers on the same machine.
|
|
680
|
+
*/
|
|
681
|
+
export async function acquireWorkerLock({
|
|
682
|
+
lockPath = workerLockPath,
|
|
683
|
+
pid = process.pid,
|
|
684
|
+
maxAttempts = 8,
|
|
685
|
+
} = {}) {
|
|
686
|
+
await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
687
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
688
|
+
const existing = readWorkerLockPid(lockPath);
|
|
689
|
+
if (existing && existing !== pid && isPidAlive(existing)) {
|
|
690
|
+
console.error(
|
|
691
|
+
`Another Jian Remote worker is running (pid ${existing}); stopping it before continuing.`,
|
|
692
|
+
);
|
|
693
|
+
terminateWorkerPid(existing);
|
|
694
|
+
}
|
|
695
|
+
if (existing && !isPidAlive(existing)) {
|
|
696
|
+
try {
|
|
697
|
+
await unlink(lockPath);
|
|
698
|
+
} catch {
|
|
699
|
+
// Lost the race to remove a stale lock; retry create.
|
|
700
|
+
}
|
|
497
701
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
702
|
+
try {
|
|
703
|
+
writeFileSync(lockPath, `${pid}\n`, { flag: 'wx', mode: 0o600 });
|
|
704
|
+
return { ok: true, pid };
|
|
705
|
+
} catch (error) {
|
|
706
|
+
if (error && error.code !== 'EEXIST') throw error;
|
|
707
|
+
sleepMs(150);
|
|
501
708
|
}
|
|
709
|
+
}
|
|
710
|
+
throw new Error(
|
|
711
|
+
`Could not claim the Jian Remote worker lock at ${lockPath}. Stop other workers with: jian-remote stop`,
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export async function releaseWorkerLock({ lockPath = workerLockPath, pid = process.pid } = {}) {
|
|
716
|
+
const existing = readWorkerLockPid(lockPath);
|
|
717
|
+
if (existing !== undefined && existing !== pid) return;
|
|
718
|
+
try {
|
|
719
|
+
await unlink(lockPath);
|
|
502
720
|
} catch {
|
|
503
|
-
//
|
|
721
|
+
// Best-effort; a stale pid file is cleared on the next acquire.
|
|
504
722
|
}
|
|
505
|
-
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function macosPlistPath() {
|
|
726
|
+
return join(homedir(), 'Library', 'LaunchAgents', `${MACOS_LAUNCH_AGENT_LABEL}.plist`);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function linuxUnitPath() {
|
|
730
|
+
return join(homedir(), '.config', 'systemd', 'user', LINUX_SERVICE_UNIT);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
export function serviceKindForPlatform(platform = process.platform) {
|
|
734
|
+
if (platform === 'darwin') return 'LaunchAgent';
|
|
735
|
+
if (platform === 'linux') return 'systemd --user';
|
|
736
|
+
if (platform === 'win32') return 'Task Scheduler';
|
|
737
|
+
return 'none';
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Fully stop the OS service and any worker still holding the pid lock.
|
|
742
|
+
* Clear success/failure for the `jian-remote stop` CLI.
|
|
743
|
+
*/
|
|
744
|
+
async function stopService() {
|
|
745
|
+
const kind = serviceKindForPlatform();
|
|
746
|
+
if (process.platform === 'darwin') {
|
|
747
|
+
const plistPath = macosPlistPath();
|
|
748
|
+
const domain = `gui/${process.getuid()}`;
|
|
749
|
+
spawnSync('launchctl', ['bootout', domain, plistPath], { stdio: 'ignore' });
|
|
750
|
+
spawnSync('launchctl', ['bootout', `${domain}/${MACOS_LAUNCH_AGENT_LABEL}`], {
|
|
751
|
+
stdio: 'ignore',
|
|
752
|
+
});
|
|
753
|
+
} else if (process.platform === 'linux') {
|
|
754
|
+
spawnSync('systemctl', ['--user', 'stop', LINUX_SERVICE_UNIT], { stdio: 'ignore' });
|
|
755
|
+
} else if (process.platform === 'win32') {
|
|
756
|
+
spawnSync('schtasks.exe', ['/End', '/TN', WINDOWS_TASK_NAME], {
|
|
757
|
+
stdio: 'ignore',
|
|
758
|
+
windowsHide: true,
|
|
759
|
+
});
|
|
760
|
+
} else {
|
|
761
|
+
throw new Error(`Service stop is unsupported on ${process.platform}.`);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const locked = readWorkerLockPid();
|
|
765
|
+
if (locked) {
|
|
766
|
+
const result = terminateWorkerPid(locked);
|
|
767
|
+
if (!result.stopped) {
|
|
768
|
+
throw new Error(
|
|
769
|
+
`Stopped the ${kind} job but worker pid ${locked} is still running. Try again or kill it manually.`,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
await releaseWorkerLock({ pid: locked ?? process.pid });
|
|
774
|
+
// Also clear a lock we never owned (stop from a CLI process).
|
|
775
|
+
try {
|
|
776
|
+
await unlink(workerLockPath);
|
|
777
|
+
} catch {
|
|
778
|
+
// absent
|
|
779
|
+
}
|
|
780
|
+
return { service: kind, stopped: true };
|
|
506
781
|
}
|
|
507
782
|
|
|
508
783
|
/**
|
|
509
|
-
* Register
|
|
784
|
+
* Register (if needed) and start exactly one worker. Always stops any previous instance first.
|
|
510
785
|
* On macOS, ProgramArguments[0] is the Jian Remote.app Node copy — not system node — so
|
|
511
786
|
* Login Items / TCC show "Jian Remote".
|
|
512
787
|
*/
|
|
513
|
-
async function
|
|
788
|
+
async function startService(script = installedScript) {
|
|
789
|
+
try {
|
|
790
|
+
await stopService();
|
|
791
|
+
} catch (error) {
|
|
792
|
+
// First install / never registered: stop may complain on exotic platforms only.
|
|
793
|
+
// A live pid that will not die must still fail the start.
|
|
794
|
+
if (error instanceof Error && /still running/i.test(error.message)) throw error;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
try {
|
|
798
|
+
await readFile(script);
|
|
799
|
+
} catch {
|
|
800
|
+
throw new Error(
|
|
801
|
+
`No installed worker at ${script}. Run: jian-remote install --gateway … --machine … --token …`,
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
|
|
514
805
|
if (process.platform === 'darwin') {
|
|
515
806
|
const launchAgents = join(homedir(), 'Library', 'LaunchAgents');
|
|
516
|
-
const plistPath =
|
|
517
|
-
const label = 'com.jian.remote';
|
|
807
|
+
const plistPath = macosPlistPath();
|
|
518
808
|
const app = await materializeMacosAppRuntime();
|
|
519
809
|
await mkdir(launchAgents, { recursive: true, mode: 0o700 });
|
|
520
810
|
await writeFile(
|
|
521
811
|
plistPath,
|
|
522
812
|
buildLaunchAgentPlist({
|
|
523
|
-
label,
|
|
813
|
+
label: MACOS_LAUNCH_AGENT_LABEL,
|
|
524
814
|
executable: app.executable,
|
|
525
815
|
script,
|
|
526
816
|
logPath: join(configDir, 'worker.log'),
|
|
@@ -528,7 +818,9 @@ async function startOrRestartService(script) {
|
|
|
528
818
|
{ mode: 0o600 },
|
|
529
819
|
);
|
|
530
820
|
const domain = `gui/${process.getuid()}`;
|
|
821
|
+
// bootout again after writing the plist so KeepAlive cannot resurrect the old definition.
|
|
531
822
|
spawnSync('launchctl', ['bootout', domain, plistPath], { stdio: 'ignore' });
|
|
823
|
+
sleepMs(400);
|
|
532
824
|
const loaded = spawnSync('launchctl', ['bootstrap', domain, plistPath], { stdio: 'inherit' });
|
|
533
825
|
if (loaded.status !== 0) throw new Error('Could not start Jian Remote with launchd');
|
|
534
826
|
return {
|
|
@@ -541,24 +833,16 @@ async function startOrRestartService(script) {
|
|
|
541
833
|
await mkdir(unitDir, { recursive: true, mode: 0o700 });
|
|
542
834
|
const quote = (value) => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
543
835
|
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`;
|
|
544
|
-
await writeFile(
|
|
545
|
-
for (const command of [
|
|
546
|
-
['daemon-reload'],
|
|
547
|
-
['enable', '--now', 'jian-remote.service'],
|
|
548
|
-
['restart', 'jian-remote.service'],
|
|
549
|
-
]) {
|
|
836
|
+
await writeFile(linuxUnitPath(), unit, { mode: 0o600 });
|
|
837
|
+
for (const command of [['daemon-reload'], ['enable', '--now', LINUX_SERVICE_UNIT]]) {
|
|
550
838
|
const result = spawnSync('systemctl', ['--user', ...command], { stdio: 'inherit' });
|
|
551
839
|
if (result.status !== 0)
|
|
552
840
|
throw new Error('Could not install/start Jian Remote with systemd --user');
|
|
553
841
|
}
|
|
554
|
-
return { service: 'systemd --user', detail:
|
|
842
|
+
return { service: 'systemd --user', detail: LINUX_SERVICE_UNIT };
|
|
555
843
|
}
|
|
556
844
|
if (process.platform === 'win32') {
|
|
557
845
|
const action = `"${process.execPath}" "${script}" run`;
|
|
558
|
-
spawnSync('schtasks.exe', ['/End', '/TN', 'Jian Remote'], {
|
|
559
|
-
stdio: 'ignore',
|
|
560
|
-
windowsHide: true,
|
|
561
|
-
});
|
|
562
846
|
runChecked(
|
|
563
847
|
'schtasks.exe',
|
|
564
848
|
[
|
|
@@ -566,7 +850,7 @@ async function startOrRestartService(script) {
|
|
|
566
850
|
'/SC',
|
|
567
851
|
'ONLOGON',
|
|
568
852
|
'/TN',
|
|
569
|
-
|
|
853
|
+
WINDOWS_TASK_NAME,
|
|
570
854
|
'/TR',
|
|
571
855
|
action,
|
|
572
856
|
'/F',
|
|
@@ -578,15 +862,49 @@ async function startOrRestartService(script) {
|
|
|
578
862
|
);
|
|
579
863
|
runChecked(
|
|
580
864
|
'schtasks.exe',
|
|
581
|
-
['/Run', '/TN',
|
|
865
|
+
['/Run', '/TN', WINDOWS_TASK_NAME],
|
|
582
866
|
'Could not start the Jian Remote scheduled task',
|
|
583
867
|
);
|
|
584
|
-
return { service: 'Task Scheduler', detail:
|
|
868
|
+
return { service: 'Task Scheduler', detail: `${WINDOWS_TASK_NAME} (ONLOGON)` };
|
|
585
869
|
}
|
|
586
870
|
printInfo(`Service auto-install is unsupported on ${process.platform}. Run: node ${script} run`);
|
|
587
871
|
return { service: 'none', detail: process.platform };
|
|
588
872
|
}
|
|
589
873
|
|
|
874
|
+
async function restartService(script = installedScript) {
|
|
875
|
+
// stopService is invoked inside startService; call stop first for clear CLI semantics / logs.
|
|
876
|
+
await stopService().catch(() => undefined);
|
|
877
|
+
return startService(script);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** @deprecated use restartService — kept as the install/configure/update entry name. */
|
|
881
|
+
async function startOrRestartService(script) {
|
|
882
|
+
return restartService(script);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async function printVersionAndExit() {
|
|
886
|
+
console.log(PACKAGE_VERSION);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async function cliStop() {
|
|
890
|
+
const result = await stopService();
|
|
891
|
+
printSuccess('Stopped Jian Remote', [['Service', `${result.service} stopped`]]);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async function cliStart() {
|
|
895
|
+
const started = await startService(installedScript);
|
|
896
|
+
const facts = [['Service', `${started.service} started`]];
|
|
897
|
+
if (started.detail) facts.push(['Identity', started.detail]);
|
|
898
|
+
printSuccess('Started Jian Remote', facts);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function cliRestart() {
|
|
902
|
+
const started = await restartService(installedScript);
|
|
903
|
+
const facts = [['Service', `${started.service} restarted`]];
|
|
904
|
+
if (started.detail) facts.push(['Identity', started.detail]);
|
|
905
|
+
printSuccess('Restarted Jian Remote', facts);
|
|
906
|
+
}
|
|
907
|
+
|
|
590
908
|
async function install() {
|
|
591
909
|
const gateway = option('--gateway');
|
|
592
910
|
const machineId = option('--machine');
|
|
@@ -709,20 +1027,40 @@ async function update() {
|
|
|
709
1027
|
let scriptBytes;
|
|
710
1028
|
let installedVersion = PACKAGE_VERSION;
|
|
711
1029
|
// When this process already matches the target (typical: npx @jian-ai/remote-control@X update),
|
|
712
|
-
// copy ourselves.
|
|
1030
|
+
// copy ourselves. Require an **exact** semver match — same major.minor alone used to let a
|
|
1031
|
+
// global 0.8.1 CLI rewrite the installed script while the gateway was already on 0.8.2,
|
|
1032
|
+
// fighting auto-update and flip-flopping two pollers.
|
|
713
1033
|
const selfMatchesTarget =
|
|
714
1034
|
target === 'latest'
|
|
715
1035
|
? !isDevVersion(PACKAGE_VERSION)
|
|
716
|
-
: PACKAGE_VERSION === target.replace(/^v/, '')
|
|
717
|
-
versionsCompatible(PACKAGE_VERSION, target).ok;
|
|
1036
|
+
: PACKAGE_VERSION === target.replace(/^v/, '');
|
|
718
1037
|
|
|
719
1038
|
if (selfMatchesTarget && !isDevVersion(PACKAGE_VERSION)) {
|
|
720
1039
|
scriptBytes = await readFile(fileURLToPath(import.meta.url));
|
|
721
1040
|
installedVersion = PACKAGE_VERSION;
|
|
722
1041
|
} else {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
1042
|
+
// Prefer the gateway’s stamped script when targeting that release — works when npm has not
|
|
1043
|
+
// published the version yet. Fall back to npm pack for pins / latest.
|
|
1044
|
+
const wantGatewayScript =
|
|
1045
|
+
gatewayVersion &&
|
|
1046
|
+
target !== 'latest' &&
|
|
1047
|
+
parseRelease(target)?.raw === parseRelease(gatewayVersion)?.raw;
|
|
1048
|
+
if (wantGatewayScript) {
|
|
1049
|
+
try {
|
|
1050
|
+
printInfo('Downloading worker script from the gateway…');
|
|
1051
|
+
scriptBytes = await fetchGatewayWorkerScript(config.gateway);
|
|
1052
|
+
installedVersion = readEmbeddedVersion(scriptBytes.toString('utf8'));
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
printInfo(
|
|
1055
|
+
`Gateway script download failed (${error instanceof Error ? error.message : error}); trying npm…`,
|
|
1056
|
+
);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
if (!scriptBytes) {
|
|
1060
|
+
printInfo(`Downloading @jian-ai/remote-control@${target}…`);
|
|
1061
|
+
scriptBytes = fetchNpmWorkerScript(target);
|
|
1062
|
+
installedVersion = readEmbeddedVersion(scriptBytes.toString('utf8'));
|
|
1063
|
+
}
|
|
726
1064
|
}
|
|
727
1065
|
|
|
728
1066
|
const error = compatibilityError(installedVersion, gatewayVersion);
|
|
@@ -752,27 +1090,125 @@ async function update() {
|
|
|
752
1090
|
printSuccess('Updated Jian Remote', facts);
|
|
753
1091
|
}
|
|
754
1092
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
1093
|
+
/** Absolute shell path for remote commands — never rely on PATH for the shell itself. */
|
|
1094
|
+
export function shellCommandForPlatform(platform = process.platform) {
|
|
1095
|
+
if (platform === 'win32') return 'powershell.exe';
|
|
1096
|
+
if (existsSync('/bin/sh')) return '/bin/sh';
|
|
1097
|
+
if (existsSync('/bin/bash')) return '/bin/bash';
|
|
1098
|
+
if (existsSync('/usr/bin/sh')) return '/usr/bin/sh';
|
|
1099
|
+
return '/bin/sh';
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Resolve the working directory for a remote shell task.
|
|
1104
|
+
* libuv reports a missing cwd as `spawn /bin/sh ENOENT` even when the shell exists — Computer
|
|
1105
|
+
* Use kept working (no cwd) while run_remote_command failed. Fall back to $HOME when the
|
|
1106
|
+
* requested path is missing or not a directory, and surface an English note in stderr.
|
|
1107
|
+
*/
|
|
1108
|
+
export function resolveShellCwd(
|
|
1109
|
+
requested,
|
|
1110
|
+
{ home = homedir(), exists = existsSync, stat = statSync } = {},
|
|
1111
|
+
) {
|
|
1112
|
+
const fallbackCandidates = [home, process.env.TMPDIR, '/tmp', '/'].filter(Boolean);
|
|
1113
|
+
const fallback =
|
|
1114
|
+
fallbackCandidates.find((path) => {
|
|
1115
|
+
try {
|
|
1116
|
+
return exists(path) && stat(path).isDirectory();
|
|
1117
|
+
} catch {
|
|
1118
|
+
return false;
|
|
1119
|
+
}
|
|
1120
|
+
}) || '/';
|
|
1121
|
+
|
|
1122
|
+
if (requested == null || requested === '') {
|
|
1123
|
+
return { cwd: fallback, note: undefined, usedFallback: false };
|
|
1124
|
+
}
|
|
1125
|
+
const target = String(requested).trim();
|
|
1126
|
+
if (!target) {
|
|
1127
|
+
return { cwd: fallback, note: undefined, usedFallback: false };
|
|
1128
|
+
}
|
|
1129
|
+
try {
|
|
1130
|
+
if (!exists(target)) {
|
|
1131
|
+
return {
|
|
1132
|
+
cwd: fallback,
|
|
1133
|
+
note: `Requested cwd ${target} does not exist on this computer; using ${fallback}.`,
|
|
1134
|
+
usedFallback: true,
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
if (!stat(target).isDirectory()) {
|
|
1138
|
+
return {
|
|
1139
|
+
cwd: fallback,
|
|
1140
|
+
note: `Requested cwd ${target} is not a directory; using ${fallback}.`,
|
|
1141
|
+
usedFallback: true,
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
return { cwd: target, note: undefined, usedFallback: false };
|
|
1145
|
+
} catch {
|
|
1146
|
+
return {
|
|
1147
|
+
cwd: fallback,
|
|
1148
|
+
note: `Requested cwd ${target} is not usable; using ${fallback}.`,
|
|
1149
|
+
usedFallback: true,
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** Env for shell / helper children: always a usable PATH and HOME. */
|
|
1155
|
+
export function workerChildEnv(baseEnv = process.env) {
|
|
1156
|
+
const pathKey = process.platform === 'win32' ? 'Path' : 'PATH';
|
|
1157
|
+
const current = baseEnv[pathKey] ?? baseEnv.PATH ?? '';
|
|
1158
|
+
const prefix = process.platform === 'win32' ? '' : LAUNCH_AGENT_PATH;
|
|
1159
|
+
const merged = prefix ? `${prefix}${current ? `:${current}` : ''}` : current || LAUNCH_AGENT_PATH;
|
|
1160
|
+
const home = baseEnv.HOME || baseEnv.USERPROFILE || homedir();
|
|
1161
|
+
return {
|
|
1162
|
+
...baseEnv,
|
|
1163
|
+
[pathKey]: merged,
|
|
1164
|
+
PATH: merged,
|
|
1165
|
+
HOME: home,
|
|
1166
|
+
...(process.platform === 'win32' ? { USERPROFILE: home } : {}),
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Explain spawn failures. Node’s `spawn /bin/sh ENOENT` usually means the **cwd** was missing,
|
|
1172
|
+
* not that /bin/sh vanished — rewrite so operators and agents can act on it.
|
|
1173
|
+
*/
|
|
1174
|
+
export function formatShellSpawnError(error, { shell, cwd } = {}) {
|
|
1175
|
+
const code = error && typeof error === 'object' ? error.code : undefined;
|
|
1176
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1177
|
+
if (code === 'ENOENT' || /\bENOENT\b/.test(message)) {
|
|
1178
|
+
const shellMissing = shell && !existsSync(shell);
|
|
1179
|
+
if (shellMissing) {
|
|
1180
|
+
return `Could not find the shell executable ${shell}. Install a POSIX shell or reinstall Jian Remote.`;
|
|
1181
|
+
}
|
|
1182
|
+
return `Could not start the remote shell (${shell || '/bin/sh'}) with cwd ${cwd || '(unknown)'}: the working directory is missing or not usable (Node reports this as spawn ENOENT even when the shell exists).`;
|
|
1183
|
+
}
|
|
1184
|
+
return message;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Run a remote shell task. Exported for behavior tests (cwd fallback, absolute shell, PATH).
|
|
1189
|
+
*/
|
|
1190
|
+
export function executeShellTask(task, { spawnImpl = spawn, platform = process.platform } = {}) {
|
|
1191
|
+
return new Promise((resolveResult) => {
|
|
1192
|
+
const shell = shellCommandForPlatform(platform);
|
|
1193
|
+
const resolved = resolveShellCwd(task.cwd);
|
|
758
1194
|
const commandArgs =
|
|
759
|
-
|
|
1195
|
+
platform === 'win32'
|
|
760
1196
|
? ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', task.command]
|
|
761
1197
|
: ['-lc', task.command];
|
|
762
|
-
const child =
|
|
763
|
-
cwd:
|
|
764
|
-
env:
|
|
1198
|
+
const child = spawnImpl(shell, commandArgs, {
|
|
1199
|
+
cwd: resolved.cwd,
|
|
1200
|
+
env: workerChildEnv(),
|
|
765
1201
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
766
1202
|
});
|
|
767
1203
|
let stdout = '';
|
|
768
|
-
let stderr = '';
|
|
1204
|
+
let stderr = resolved.note ? `${resolved.note}\n` : '';
|
|
769
1205
|
let stdoutSeen = 0;
|
|
770
1206
|
let timedOut = false;
|
|
771
1207
|
const timeout = setTimeout(() => {
|
|
772
1208
|
timedOut = true;
|
|
773
1209
|
child.kill('SIGTERM');
|
|
774
1210
|
setTimeout(() => {
|
|
775
|
-
if (
|
|
1211
|
+
if (platform === 'win32' && child.pid) {
|
|
776
1212
|
spawnSync('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
|
|
777
1213
|
stdio: 'ignore',
|
|
778
1214
|
windowsHide: true,
|
|
@@ -782,11 +1218,11 @@ async function executeShell(task) {
|
|
|
782
1218
|
}
|
|
783
1219
|
}, 2000).unref();
|
|
784
1220
|
}, task.timeoutMs);
|
|
785
|
-
child.stdout
|
|
1221
|
+
child.stdout?.on('data', (chunk) => {
|
|
786
1222
|
stdoutSeen += chunk.toString('utf8').length;
|
|
787
1223
|
stdout = cappedAppend(stdout, chunk, STDOUT_CAP);
|
|
788
1224
|
});
|
|
789
|
-
child.stderr
|
|
1225
|
+
child.stderr?.on('data', (chunk) => {
|
|
790
1226
|
stderr = cappedAppend(stderr, chunk, STDERR_CAP);
|
|
791
1227
|
});
|
|
792
1228
|
child.on('error', (error) => {
|
|
@@ -795,13 +1231,16 @@ async function executeShell(task) {
|
|
|
795
1231
|
taskId: task.id,
|
|
796
1232
|
exitCode: null,
|
|
797
1233
|
stdout,
|
|
798
|
-
stderr: cappedAppend(
|
|
1234
|
+
stderr: cappedAppend(
|
|
1235
|
+
stderr,
|
|
1236
|
+
formatShellSpawnError(error, { shell, cwd: resolved.cwd }),
|
|
1237
|
+
STDERR_CAP,
|
|
1238
|
+
),
|
|
799
1239
|
timedOut,
|
|
800
1240
|
});
|
|
801
1241
|
});
|
|
802
1242
|
child.on('close', (code) => {
|
|
803
1243
|
clearTimeout(timeout);
|
|
804
|
-
// Mark silent stdout cuts so screenshot tools can reject grey/truncated JPEGs.
|
|
805
1244
|
if (stdoutSeen > STDOUT_CAP) {
|
|
806
1245
|
stderr = cappedAppend(
|
|
807
1246
|
stderr,
|
|
@@ -814,26 +1253,124 @@ async function executeShell(task) {
|
|
|
814
1253
|
});
|
|
815
1254
|
}
|
|
816
1255
|
|
|
1256
|
+
async function executeShell(task) {
|
|
1257
|
+
return executeShellTask(task);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
817
1260
|
async function execute(task) {
|
|
818
1261
|
if (task.kind === 'computer') {
|
|
819
|
-
|
|
820
|
-
return {
|
|
821
|
-
taskId: task.id,
|
|
822
|
-
exitCode: 1,
|
|
823
|
-
stdout: '',
|
|
824
|
-
stderr: 'Computer use is supported on macOS Jian Remote only.',
|
|
825
|
-
timedOut: false,
|
|
826
|
-
computer: { ok: false, error: 'Computer use is supported on macOS Jian Remote only.' },
|
|
827
|
-
};
|
|
828
|
-
}
|
|
829
|
-
const { executeComputerTask } = await import('./computer-macos.mjs');
|
|
1262
|
+
const { executeComputerTask } = await import('./computer.mjs');
|
|
830
1263
|
return executeComputerTask(task);
|
|
831
1264
|
}
|
|
832
1265
|
return executeShell(task);
|
|
833
1266
|
}
|
|
834
1267
|
|
|
1268
|
+
async function readUpdateState() {
|
|
1269
|
+
try {
|
|
1270
|
+
const raw = JSON.parse(await readFile(updateStatePath, 'utf8'));
|
|
1271
|
+
return {
|
|
1272
|
+
failures: Number(raw?.failures) || 0,
|
|
1273
|
+
lastError: typeof raw?.lastError === 'string' ? raw.lastError : undefined,
|
|
1274
|
+
lastFailureAt: typeof raw?.lastFailureAt === 'string' ? raw.lastFailureAt : undefined,
|
|
1275
|
+
};
|
|
1276
|
+
} catch {
|
|
1277
|
+
return { failures: 0 };
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
async function clearUpdateState() {
|
|
1282
|
+
try {
|
|
1283
|
+
rmSync(updateStatePath, { force: true });
|
|
1284
|
+
} catch {
|
|
1285
|
+
// Best-effort; a stale state file only delays the next retry.
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
async function recordUpdateFailure(error) {
|
|
1290
|
+
const previous = await readUpdateState();
|
|
1291
|
+
const failures = previous.failures + 1;
|
|
1292
|
+
const lastError = error instanceof Error ? error.message : String(error);
|
|
1293
|
+
await mkdir(configDir, { recursive: true, mode: 0o700 });
|
|
1294
|
+
await writeFile(
|
|
1295
|
+
updateStatePath,
|
|
1296
|
+
`${JSON.stringify(
|
|
1297
|
+
{
|
|
1298
|
+
failures,
|
|
1299
|
+
lastError,
|
|
1300
|
+
lastFailureAt: new Date().toISOString(),
|
|
1301
|
+
},
|
|
1302
|
+
null,
|
|
1303
|
+
2,
|
|
1304
|
+
)}\n`,
|
|
1305
|
+
{ mode: 0o600 },
|
|
1306
|
+
);
|
|
1307
|
+
return { failures, lastError, backoffMs: nextUpdateBackoffMs(failures) };
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* True when this process’s PACKAGE_VERSION no longer matches the installed script on disk.
|
|
1312
|
+
* A surviving poller after another worker already applied an update must exit so KeepAlive
|
|
1313
|
+
* loads the new file — ETag 304 alone is not enough (disk matches gateway, memory is stale).
|
|
1314
|
+
*/
|
|
1315
|
+
export function isStaleInstalledWorker(processVersion, installedSource) {
|
|
1316
|
+
if (isDevVersion(processVersion)) return false;
|
|
1317
|
+
if (typeof installedSource !== 'string' || !installedSource) return false;
|
|
1318
|
+
const diskVersion = readEmbeddedVersion(installedSource);
|
|
1319
|
+
if (!diskVersion || diskVersion === 'unknown' || isDevVersion(diskVersion)) return false;
|
|
1320
|
+
return diskVersion !== processVersion;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Atomically replace the installed worker script and exit so LaunchAgent / systemd KeepAlive
|
|
1325
|
+
* (or a detached Windows child) load the new code. Never touches config.json.
|
|
1326
|
+
* Does not call startOrRestartService — that would race KeepAlive and leave two pollers.
|
|
1327
|
+
*/
|
|
1328
|
+
async function applyWorkerScriptAndRestart(updated, { sourceLabel }) {
|
|
1329
|
+
const checked = validateWorkerScriptBytes(updated);
|
|
1330
|
+
if (!checked.ok) throw new Error(checked.message);
|
|
1331
|
+
// Same bytes already on disk and already loaded — do not thrash restart loops when npm and
|
|
1332
|
+
// the gateway script disagree about packaging but stamp the same version.
|
|
1333
|
+
try {
|
|
1334
|
+
const existing = await readFile(installedScript);
|
|
1335
|
+
if (
|
|
1336
|
+
existing.equals(Buffer.isBuffer(updated) ? updated : Buffer.from(updated)) &&
|
|
1337
|
+
checked.version === PACKAGE_VERSION
|
|
1338
|
+
) {
|
|
1339
|
+
await clearUpdateState();
|
|
1340
|
+
return false;
|
|
1341
|
+
}
|
|
1342
|
+
} catch {
|
|
1343
|
+
// No installed script yet — fall through and write.
|
|
1344
|
+
}
|
|
1345
|
+
await mkdir(configDir, { recursive: true, mode: 0o700 });
|
|
1346
|
+
const temp = `${installedScript}.${process.pid}.tmp`;
|
|
1347
|
+
await writeFile(temp, updated, { mode: 0o700 });
|
|
1348
|
+
await rename(temp, installedScript);
|
|
1349
|
+
if (process.platform !== 'win32') await chmod(installedScript, 0o700);
|
|
1350
|
+
// Best-effort companions from this process’s package (gateway /update is main script only).
|
|
1351
|
+
await materializeCompanionModules();
|
|
1352
|
+
await clearUpdateState();
|
|
1353
|
+
console.log(`Worker updated to ${checked.version} from ${sourceLabel}; restarting to apply it.`);
|
|
1354
|
+
await releaseWorkerLock();
|
|
1355
|
+
if (process.platform === 'win32') {
|
|
1356
|
+
// Task Scheduler has no KeepAlive; spawn the new script then exit this process.
|
|
1357
|
+
// Only one detached child — do not also leave this process polling.
|
|
1358
|
+
const child = spawn(process.execPath, [installedScript, 'run'], {
|
|
1359
|
+
detached: true,
|
|
1360
|
+
stdio: 'ignore',
|
|
1361
|
+
windowsHide: true,
|
|
1362
|
+
});
|
|
1363
|
+
child.unref();
|
|
1364
|
+
}
|
|
1365
|
+
// macOS LaunchAgent KeepAlive / Linux systemd Restart=always reload this path.
|
|
1366
|
+
process.exit(0);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
/**
|
|
1370
|
+
* Pull the gateway-served worker (same bytes as /jian-remote.mjs, auth’d). Prefer this over npm
|
|
1371
|
+
* for fleets that can reach the gateway but not the registry.
|
|
1372
|
+
*/
|
|
835
1373
|
async function updateIfNeeded(base, headers) {
|
|
836
|
-
// Prefer the installed copy under ~/.config so updates land where the service points.
|
|
837
1374
|
const script = installedScript;
|
|
838
1375
|
let local;
|
|
839
1376
|
try {
|
|
@@ -841,30 +1378,170 @@ async function updateIfNeeded(base, headers) {
|
|
|
841
1378
|
} catch {
|
|
842
1379
|
local = await readFile(fileURLToPath(import.meta.url));
|
|
843
1380
|
}
|
|
1381
|
+
if (isStaleInstalledWorker(PACKAGE_VERSION, local.toString('utf8'))) {
|
|
1382
|
+
console.log(
|
|
1383
|
+
`Worker process ${PACKAGE_VERSION} is stale (installed script is newer); restarting to load it.`,
|
|
1384
|
+
);
|
|
1385
|
+
await releaseWorkerLock();
|
|
1386
|
+
process.exit(0);
|
|
1387
|
+
}
|
|
844
1388
|
const etag = `"${createHash('sha256').update(local).digest('hex')}"`;
|
|
845
1389
|
const response = await fetch(`${base}/update`, {
|
|
846
1390
|
headers: { ...headers, 'if-none-match': etag },
|
|
847
1391
|
redirect: 'error',
|
|
848
1392
|
signal: AbortSignal.timeout(15_000),
|
|
849
1393
|
});
|
|
850
|
-
if (response.status === 304)
|
|
1394
|
+
if (response.status === 304) {
|
|
1395
|
+
// Disk matches gateway. If this process is still the wrong release, exit to reload disk
|
|
1396
|
+
// (second poller that survived an earlier update).
|
|
1397
|
+
return false;
|
|
1398
|
+
}
|
|
851
1399
|
if (!response.ok) throw new Error(`worker update check failed: HTTP ${response.status}`);
|
|
852
1400
|
const updated = Buffer.from(await response.arrayBuffer());
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
1401
|
+
const applied = await applyWorkerScriptAndRestart(updated, { sourceLabel: 'the gateway' });
|
|
1402
|
+
// applyWorkerScriptAndRestart exits on a real replace; false means already current.
|
|
1403
|
+
return applied !== false;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/** Public /jian-remote.mjs — same stamped bytes as authenticated /update, no worker token. */
|
|
1407
|
+
async function fetchGatewayWorkerScript(gatewayBase, fetcher = fetch) {
|
|
1408
|
+
const response = await fetcher(`${gatewayBase.replace(/\/$/, '')}/jian-remote.mjs`, {
|
|
1409
|
+
redirect: 'error',
|
|
1410
|
+
signal: AbortSignal.timeout(30_000),
|
|
1411
|
+
});
|
|
1412
|
+
if (!response.ok) {
|
|
1413
|
+
throw new Error(`Could not download gateway worker script: HTTP ${response.status}`);
|
|
866
1414
|
}
|
|
867
|
-
|
|
1415
|
+
return Buffer.from(await response.arrayBuffer());
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* Fallback when authenticated /update did not leave disk on the gateway release.
|
|
1420
|
+
* Prefer public /jian-remote.mjs (works when npm is not promoted yet); then npm pack.
|
|
1421
|
+
*/
|
|
1422
|
+
async function updateFromNpm(gatewayVersion, gatewayBase) {
|
|
1423
|
+
const target = parseRelease(gatewayVersion)?.raw;
|
|
1424
|
+
if (!target)
|
|
1425
|
+
throw new Error('Cannot download a worker update without a gateway release version.');
|
|
1426
|
+
if (gatewayBase) {
|
|
1427
|
+
try {
|
|
1428
|
+
console.log(`Downloading worker script from the gateway (/jian-remote.mjs)…`);
|
|
1429
|
+
const fromGateway = await fetchGatewayWorkerScript(gatewayBase);
|
|
1430
|
+
const applied = await applyWorkerScriptAndRestart(fromGateway, {
|
|
1431
|
+
sourceLabel: 'the gateway (/jian-remote.mjs)',
|
|
1432
|
+
});
|
|
1433
|
+
if (applied !== false) return true;
|
|
1434
|
+
} catch (error) {
|
|
1435
|
+
console.error(
|
|
1436
|
+
`Gateway script download failed (${error instanceof Error ? error.message : error}); trying npm…`,
|
|
1437
|
+
);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
console.log(`Downloading @jian-ai/remote-control@${target} from npm…`);
|
|
1441
|
+
const scriptBytes = fetchNpmWorkerScript(target);
|
|
1442
|
+
const applied = await applyWorkerScriptAndRestart(scriptBytes, {
|
|
1443
|
+
sourceLabel: `npm @${target}`,
|
|
1444
|
+
});
|
|
1445
|
+
return applied !== false;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* On connect and every few minutes: match the gateway’s worker script. Same major.minor patch
|
|
1450
|
+
* bumps use the authenticated /update ETag path; major.minor skew also self-updates to the
|
|
1451
|
+
* gateway version (gateway script first, npm fallback) instead of refusing forever.
|
|
1452
|
+
*
|
|
1453
|
+
* Prefer the gateway script. Only hit npm when the gateway path did not leave the **installed**
|
|
1454
|
+
* script on the gateway release — never rewrite a correct gateway install with an older npm
|
|
1455
|
+
* tarball (staged-but-unpublished packages caused 0.8.1↔0.8.2 thrash).
|
|
1456
|
+
*/
|
|
1457
|
+
async function reconcileWorkerWithGateway(config, base, headers) {
|
|
1458
|
+
const gatewayVersion = await fetchGatewayVersion(config.gateway);
|
|
1459
|
+
const gatewayRelease = parseRelease(gatewayVersion)?.raw;
|
|
1460
|
+
const compat = versionsCompatible(PACKAGE_VERSION, gatewayVersion);
|
|
1461
|
+
const mustMatch = shouldMatchGatewayVersion(PACKAGE_VERSION, gatewayVersion);
|
|
1462
|
+
|
|
1463
|
+
let installedVersion;
|
|
1464
|
+
try {
|
|
1465
|
+
installedVersion = readEmbeddedVersion((await readFile(installedScript)).toString('utf8'));
|
|
1466
|
+
} catch {
|
|
1467
|
+
installedVersion = undefined;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
let gatewayUpdateError;
|
|
1471
|
+
try {
|
|
1472
|
+
const changed = await updateIfNeeded(base, headers);
|
|
1473
|
+
// Re-read after a no-op check; updateIfNeeded exits on apply.
|
|
1474
|
+
try {
|
|
1475
|
+
installedVersion = readEmbeddedVersion((await readFile(installedScript)).toString('utf8'));
|
|
1476
|
+
} catch {
|
|
1477
|
+
// keep previous
|
|
1478
|
+
}
|
|
1479
|
+
// 304 and still wrong version → gateway bytes match disk but this process is stale, or the
|
|
1480
|
+
// gateway could not stamp a newer script. Fall through to npm only when disk is wrong too.
|
|
1481
|
+
if (!changed && mustMatch) {
|
|
1482
|
+
if (gatewayRelease && installedVersion === gatewayRelease) {
|
|
1483
|
+
console.log(
|
|
1484
|
+
`Installed worker is ${installedVersion} but this process is ${PACKAGE_VERSION}; restarting to load the installed script.`,
|
|
1485
|
+
);
|
|
1486
|
+
await releaseWorkerLock();
|
|
1487
|
+
process.exit(0);
|
|
1488
|
+
}
|
|
1489
|
+
gatewayUpdateError = new Error(
|
|
1490
|
+
`Gateway worker script is unchanged but this process is ${PACKAGE_VERSION} (gateway ${gatewayVersion}).`,
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
} catch (error) {
|
|
1494
|
+
gatewayUpdateError = error instanceof Error ? error : new Error(String(error));
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
const diskAlreadyMatches = Boolean(gatewayRelease) && installedVersion === gatewayRelease;
|
|
1498
|
+
|
|
1499
|
+
if (gatewayUpdateError && (mustMatch || !compat.ok) && !diskAlreadyMatches) {
|
|
1500
|
+
console.error(
|
|
1501
|
+
`Gateway self-update did not apply (${gatewayUpdateError.message}); trying npm for ${gatewayVersion}…`,
|
|
1502
|
+
);
|
|
1503
|
+
try {
|
|
1504
|
+
await updateFromNpm(gatewayVersion, config.gateway);
|
|
1505
|
+
} catch (npmError) {
|
|
1506
|
+
const recorded = await recordUpdateFailure(npmError);
|
|
1507
|
+
console.error(
|
|
1508
|
+
`Automatic worker update failed (${recorded.lastError}). Will retry after ${(recorded.backoffMs / 1000).toFixed(0)}s. Credentials in ${configPath} were not changed.`,
|
|
1509
|
+
);
|
|
1510
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, recorded.backoffMs));
|
|
1511
|
+
if (!compat.ok) {
|
|
1512
|
+
console.error(
|
|
1513
|
+
compatibilityError(PACKAGE_VERSION, gatewayVersion) ??
|
|
1514
|
+
`This remote-control ${PACKAGE_VERSION} cannot connect to gateway ${gatewayVersion}.`,
|
|
1515
|
+
);
|
|
1516
|
+
process.exit(1);
|
|
1517
|
+
}
|
|
1518
|
+
// Compatible (same major.minor) but newer patch unavailable — keep running on the old build.
|
|
1519
|
+
console.error('Continuing on the installed worker; will retry the update later.');
|
|
1520
|
+
return gatewayVersion;
|
|
1521
|
+
}
|
|
1522
|
+
} else if (gatewayUpdateError && diskAlreadyMatches) {
|
|
1523
|
+
console.error(
|
|
1524
|
+
`Worker update check reported ${gatewayUpdateError.message}; installed script already matches gateway ${gatewayRelease}. Not rewriting from npm.`,
|
|
1525
|
+
);
|
|
1526
|
+
} else if (gatewayUpdateError) {
|
|
1527
|
+
// Optional content refresh failed while still compatible — log and keep polling.
|
|
1528
|
+
console.error(`Worker update check failed: ${gatewayUpdateError.message}`);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
if (!compat.ok) {
|
|
1532
|
+
// Update paths returned without exiting (should be rare); refuse with backoff.
|
|
1533
|
+
const recorded = await recordUpdateFailure(
|
|
1534
|
+
new Error(`Still incompatible with gateway ${gatewayVersion} after update attempts.`),
|
|
1535
|
+
);
|
|
1536
|
+
console.error(
|
|
1537
|
+
compatibilityError(PACKAGE_VERSION, gatewayVersion) ??
|
|
1538
|
+
`This remote-control ${PACKAGE_VERSION} cannot connect to gateway ${gatewayVersion}.`,
|
|
1539
|
+
);
|
|
1540
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, recorded.backoffMs));
|
|
1541
|
+
process.exit(1);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
return gatewayVersion;
|
|
868
1545
|
}
|
|
869
1546
|
|
|
870
1547
|
async function postResult(base, headers, result) {
|
|
@@ -938,49 +1615,77 @@ async function postResult(base, headers, result) {
|
|
|
938
1615
|
}
|
|
939
1616
|
|
|
940
1617
|
async function run() {
|
|
1618
|
+
await acquireWorkerLock();
|
|
1619
|
+
let stopMacIdle = () => {};
|
|
1620
|
+
const shutdown = () => {
|
|
1621
|
+
try {
|
|
1622
|
+
stopMacIdle();
|
|
1623
|
+
} catch {
|
|
1624
|
+
// ignore
|
|
1625
|
+
}
|
|
1626
|
+
try {
|
|
1627
|
+
const existing = readWorkerLockPid();
|
|
1628
|
+
if (existing === process.pid) rmSync(workerLockPath, { force: true });
|
|
1629
|
+
} catch {
|
|
1630
|
+
// Best-effort unlock.
|
|
1631
|
+
}
|
|
1632
|
+
};
|
|
1633
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
1634
|
+
process.once(signal, () => {
|
|
1635
|
+
shutdown();
|
|
1636
|
+
process.exit(0);
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
process.once('exit', shutdown);
|
|
1640
|
+
|
|
941
1641
|
const config = await loadConfig();
|
|
942
1642
|
const capabilities = [];
|
|
1643
|
+
let probeMacUiState = () => 'unknown';
|
|
1644
|
+
try {
|
|
1645
|
+
const { probeComputerCapability, computerCapabilityName } = await import('./computer.mjs');
|
|
1646
|
+
const name = computerCapabilityName();
|
|
1647
|
+
if (name && (await probeComputerCapability())) capabilities.push(name);
|
|
1648
|
+
} catch {
|
|
1649
|
+
// Computer use unavailable until helper is ready; shell tasks still work.
|
|
1650
|
+
}
|
|
943
1651
|
if (process.platform === 'darwin') {
|
|
944
1652
|
try {
|
|
945
|
-
const
|
|
946
|
-
|
|
1653
|
+
const mac = await import('./computer-macos.mjs');
|
|
1654
|
+
mac.startWorkerIdleAssertion(process.pid);
|
|
1655
|
+
stopMacIdle = () => mac.stopWorkerIdleAssertion();
|
|
1656
|
+
probeMacUiState = () => mac.probeMacUiState();
|
|
947
1657
|
} catch {
|
|
948
|
-
//
|
|
1658
|
+
// Idle assertion / uiState probe optional; shell still works.
|
|
949
1659
|
}
|
|
950
1660
|
}
|
|
951
|
-
const
|
|
1661
|
+
const pollHeaders = () => ({
|
|
952
1662
|
authorization: `Bearer ${config.token}`,
|
|
953
1663
|
'x-jian-platform': `${process.platform}-${process.arch}`,
|
|
954
1664
|
'x-jian-remote-version': PACKAGE_VERSION,
|
|
1665
|
+
'x-jian-remote-ui-state': probeMacUiState(),
|
|
955
1666
|
...(capabilities.length ? { 'x-jian-remote-capabilities': capabilities.join(',') } : {}),
|
|
956
|
-
};
|
|
1667
|
+
});
|
|
957
1668
|
const base = `${config.gateway}/v1/remote/worker/${config.machineId}`;
|
|
958
|
-
//
|
|
959
|
-
|
|
1669
|
+
// Match the gateway worker (including major.minor skew) before polling so fleets do not
|
|
1670
|
+
// crash-loop on refuse without attempting a self-update first.
|
|
1671
|
+
const gatewayVersion = await reconcileWorkerWithGateway(config, base, pollHeaders());
|
|
960
1672
|
console.log(
|
|
961
1673
|
`Connected to Jian Remote at ${config.gateway} (worker ${PACKAGE_VERSION}${gatewayVersion ? `, gateway ${gatewayVersion}` : ''}${capabilities.length ? `, capabilities ${capabilities.join(',')}` : ''})`,
|
|
962
1674
|
);
|
|
963
1675
|
let failures = 0;
|
|
964
|
-
|
|
1676
|
+
// First reconcile already ran; space the next check like the historical five-minute cadence.
|
|
1677
|
+
let nextUpdateCheck = Date.now() + 5 * 60_000;
|
|
965
1678
|
const delay = async (attempt) => {
|
|
966
1679
|
const ceiling = Math.min(60_000, 1000 * 2 ** Math.min(attempt, 6));
|
|
967
1680
|
const wait = Math.random() * ceiling;
|
|
968
1681
|
console.error(`Retrying in ${(wait / 1000).toFixed(1)}s (attempt ${attempt})…`);
|
|
969
1682
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, wait));
|
|
970
1683
|
};
|
|
971
|
-
const ensureCompatible = async () => {
|
|
972
|
-
const current = await fetchGatewayVersion(config.gateway);
|
|
973
|
-
const error = compatibilityError(PACKAGE_VERSION, current);
|
|
974
|
-
if (error) {
|
|
975
|
-
console.error(error);
|
|
976
|
-
process.exit(1);
|
|
977
|
-
}
|
|
978
|
-
};
|
|
979
1684
|
while (true) {
|
|
980
1685
|
try {
|
|
1686
|
+
const headers = pollHeaders();
|
|
981
1687
|
if (Date.now() >= nextUpdateCheck) {
|
|
982
|
-
await
|
|
983
|
-
await updateIfNeeded(base, headers);
|
|
1688
|
+
await reconcileWorkerWithGateway(config, base, headers);
|
|
984
1689
|
nextUpdateCheck = Date.now() + 5 * 60_000;
|
|
985
1690
|
}
|
|
986
1691
|
const response = await fetch(`${base}/tasks/next`, {
|
|
@@ -1024,25 +1729,34 @@ export function isCliEntry(argv1 = process.argv[1], metaUrl = import.meta.url) {
|
|
|
1024
1729
|
|
|
1025
1730
|
if (isCliEntry()) {
|
|
1026
1731
|
try {
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
else if (
|
|
1030
|
-
else if (
|
|
1732
|
+
const invocation = parseCliInvocation(args);
|
|
1733
|
+
if (invocation.command === 'version') await printVersionAndExit();
|
|
1734
|
+
else if (invocation.command === 'install') await install();
|
|
1735
|
+
else if (invocation.command === 'configure') await configure();
|
|
1736
|
+
else if (invocation.command === 'update') await update();
|
|
1737
|
+
else if (invocation.command === 'stop') await cliStop();
|
|
1738
|
+
else if (invocation.command === 'start') await cliStart();
|
|
1739
|
+
else if (invocation.command === 'restart') await cliRestart();
|
|
1740
|
+
else if (invocation.command === 'run') await run();
|
|
1031
1741
|
else {
|
|
1032
1742
|
const color = shouldColorize();
|
|
1033
1743
|
const title = paint('Jian Remote', ANSI.bold, color);
|
|
1034
1744
|
const pkg = paint('(@jian-ai/remote-control)', ANSI.dim, color);
|
|
1035
1745
|
console.log(`${title} worker ${pkg}
|
|
1746
|
+
version | --version | -V
|
|
1036
1747
|
install --gateway HTTPS_URL --machine UUID (--token VALUE | --token-prompt | --token-stdin)
|
|
1037
1748
|
configure [--gateway HTTPS_URL] [--machine UUID] (--token VALUE | --token-prompt | --token-stdin)
|
|
1038
1749
|
update [--version X.Y.Z|latest]
|
|
1750
|
+
stop | start | restart
|
|
1039
1751
|
run
|
|
1040
1752
|
|
|
1041
1753
|
Install: npm install -g @jian-ai/remote-control
|
|
1042
1754
|
Or: npx @jian-ai/remote-control@latest install --gateway … --machine … --token …
|
|
1043
1755
|
Configure: jian-remote configure --token … (or --gateway / --machine; restarts the service)
|
|
1044
1756
|
Update: npx @jian-ai/remote-control@latest update
|
|
1045
|
-
(upgrades the worker binary; reads ~/.config/jian-remote)
|
|
1757
|
+
(upgrades the worker binary; reads ~/.config/jian-remote)
|
|
1758
|
+
Service: jian-remote stop | start | restart
|
|
1759
|
+
Version: jian-remote version`);
|
|
1046
1760
|
}
|
|
1047
1761
|
} catch (error) {
|
|
1048
1762
|
const color = shouldColorize({ isTTY: process.stderr.isTTY });
|