@jian-ai/remote-control 0.8.2 → 0.8.4
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 +16 -4
- package/bin/computer-macos.mjs +52 -39
- package/bin/jian-remote.mjs +1143 -104
- package/linux/computer-helper.py +5 -4
- package/macos/computer-helper +0 -0
- package/macos/computer-helper.swift +238 -57
- package/macos/jian-pointer.png +0 -0
- package/package.json +1 -1
- package/windows/computer-helper.ps1 +5 -4
package/bin/jian-remote.mjs
CHANGED
|
@@ -1,27 +1,70 @@
|
|
|
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
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
statSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import {
|
|
15
|
+
chmod,
|
|
16
|
+
copyFile,
|
|
17
|
+
mkdir,
|
|
18
|
+
readdir,
|
|
19
|
+
readFile,
|
|
20
|
+
rename,
|
|
21
|
+
rm,
|
|
22
|
+
unlink,
|
|
23
|
+
writeFile,
|
|
24
|
+
} from 'node:fs/promises';
|
|
6
25
|
import { homedir, tmpdir } from 'node:os';
|
|
7
|
-
import { dirname, join, resolve } from 'node:path';
|
|
26
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
8
27
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
9
28
|
|
|
10
29
|
const configDir = join(homedir(), '.config', 'jian-remote');
|
|
11
30
|
const configPath = join(configDir, 'config.json');
|
|
12
31
|
/** Stable path the OS service runs — not the npx cache or a transient install location. */
|
|
13
32
|
const installedScript = join(configDir, 'jian-remote.mjs');
|
|
33
|
+
/** Exclusive run lock — prevents KeepAlive / CLI races from leaving two pollers alive. */
|
|
34
|
+
const workerLockPath = join(configDir, 'worker.pid');
|
|
35
|
+
const updateStatePath = join(configDir, 'update-state.json');
|
|
36
|
+
const MACOS_LAUNCH_AGENT_LABEL = 'com.jian.remote';
|
|
37
|
+
const LINUX_SERVICE_UNIT = 'jian-remote.service';
|
|
38
|
+
const WINDOWS_TASK_NAME = 'Jian Remote';
|
|
39
|
+
/** Minimal PATH for launchd — app-identity Node copies otherwise inherit an empty env. */
|
|
40
|
+
export const LAUNCH_AGENT_PATH = '/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin';
|
|
41
|
+
/** KeepAlive floor so a bad update cannot spin-restart every second. */
|
|
42
|
+
export const LAUNCH_AGENT_THROTTLE_INTERVAL_SEC = 10;
|
|
43
|
+
|
|
14
44
|
const args = process.argv.slice(2);
|
|
15
45
|
const option = (name) => {
|
|
16
46
|
const index = args.indexOf(name);
|
|
17
47
|
return index < 0 ? undefined : args[index + 1];
|
|
18
48
|
};
|
|
19
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Parse argv for the top-level command and version flags (`version`, `--version`, `-V`).
|
|
52
|
+
* Version flags win over a subcommand so `jian-remote --version update` still prints the version.
|
|
53
|
+
*/
|
|
54
|
+
export function parseCliInvocation(argv = args) {
|
|
55
|
+
const list = Array.isArray(argv) ? argv : [];
|
|
56
|
+
if (list.includes('--version') || list.includes('-V') || list[0] === 'version') {
|
|
57
|
+
return { command: 'version', versionRequested: true };
|
|
58
|
+
}
|
|
59
|
+
const command = list[0] && !list[0].startsWith('-') ? list[0] : undefined;
|
|
60
|
+
return { command, versionRequested: false };
|
|
61
|
+
}
|
|
62
|
+
|
|
20
63
|
/**
|
|
21
64
|
* Stamped to the release tag on npm publish and when the gateway serves this script.
|
|
22
65
|
* Compatibility with the gateway requires the same major.minor (see versionsCompatible).
|
|
23
66
|
*/
|
|
24
|
-
export const PACKAGE_VERSION = '0.8.
|
|
67
|
+
export const PACKAGE_VERSION = '0.8.4';
|
|
25
68
|
|
|
26
69
|
/** Soft stdout/stderr caps while capturing a command (chars). Gateway schema matches these. */
|
|
27
70
|
const STDOUT_CAP = 400_000;
|
|
@@ -110,12 +153,291 @@ export function macosAppBundlePaths(home = homedir()) {
|
|
|
110
153
|
appRoot,
|
|
111
154
|
contents: join(appRoot, 'Contents'),
|
|
112
155
|
macosDir: join(appRoot, 'Contents', 'MacOS'),
|
|
156
|
+
/** Homebrew / official Node often load `@rpath/libnode.*.dylib` from here. */
|
|
157
|
+
libDir: join(appRoot, 'Contents', 'lib'),
|
|
113
158
|
executable: join(appRoot, 'Contents', 'MacOS', MACOS_APP_NAME),
|
|
114
159
|
infoPlist: join(appRoot, 'Contents', 'Info.plist'),
|
|
115
160
|
bundleId: MACOS_BUNDLE_ID,
|
|
116
161
|
};
|
|
117
162
|
}
|
|
118
163
|
|
|
164
|
+
/** dyld system locations — leave these on the host; do not copy into the app. */
|
|
165
|
+
export function isMacSystemLibrary(depPath) {
|
|
166
|
+
if (typeof depPath !== 'string' || !depPath) return true;
|
|
167
|
+
if (depPath.startsWith('/usr/lib/')) return true;
|
|
168
|
+
if (depPath.startsWith('/System/')) return true;
|
|
169
|
+
if (depPath.startsWith('/Library/Apple/')) return true;
|
|
170
|
+
if (depPath.startsWith('/usr/local/lib/libSystem')) return true;
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Parse `otool -L` lines into load-command paths (skips the header "path:" line).
|
|
176
|
+
* Example line: `\t@rpath/libnode.147.dylib (compatibility version 147.0.0, current version …)`
|
|
177
|
+
*/
|
|
178
|
+
export function parseOtoolDependencies(stdout) {
|
|
179
|
+
const deps = [];
|
|
180
|
+
for (const line of String(stdout || '').split('\n')) {
|
|
181
|
+
const trimmed = line.trim();
|
|
182
|
+
if (!trimmed || trimmed.endsWith(':')) continue;
|
|
183
|
+
const match = /^(.+?)\s+\(compatibility version\b/.exec(trimmed);
|
|
184
|
+
if (!match) continue;
|
|
185
|
+
const dep = match[1].trim();
|
|
186
|
+
if (dep) deps.push(dep);
|
|
187
|
+
}
|
|
188
|
+
return deps;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Parse LC_RPATH entries from `otool -l` (or `otool -l -v`) output.
|
|
193
|
+
* Looks for: `cmd LC_RPATH` … `path <value> (offset …)`
|
|
194
|
+
*/
|
|
195
|
+
export function parseOtoolRpaths(stdout) {
|
|
196
|
+
const rpaths = [];
|
|
197
|
+
const lines = String(stdout || '').split('\n');
|
|
198
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
199
|
+
if (!/\bLC_RPATH\b/.test(lines[i])) continue;
|
|
200
|
+
for (let j = i + 1; j < Math.min(i + 6, lines.length); j += 1) {
|
|
201
|
+
const match = /^\s*path\s+(.+?)(?:\s+\(offset\s+\d+\))?\s*$/.exec(lines[j]);
|
|
202
|
+
if (match) {
|
|
203
|
+
rpaths.push(match[1].trim());
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return rpaths;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Expand @executable_path / @loader_path / @rpath against the source Node layout.
|
|
213
|
+
* Absolute paths pass through. Unresolvable @rpath names return null.
|
|
214
|
+
*/
|
|
215
|
+
export function resolveMachODependency(
|
|
216
|
+
dep,
|
|
217
|
+
{ binaryPath, executablePath, rpaths = [], exists = existsSync } = {},
|
|
218
|
+
) {
|
|
219
|
+
if (typeof dep !== 'string' || !dep) return null;
|
|
220
|
+
const loaderDir = dirname(binaryPath);
|
|
221
|
+
const execDir = dirname(executablePath || binaryPath);
|
|
222
|
+
|
|
223
|
+
const expandPrefix = (value) => {
|
|
224
|
+
if (value.startsWith('@loader_path')) {
|
|
225
|
+
return join(loaderDir, value.slice('@loader_path'.length).replace(/^\//, '') || '.');
|
|
226
|
+
}
|
|
227
|
+
if (value.startsWith('@executable_path')) {
|
|
228
|
+
return join(execDir, value.slice('@executable_path'.length).replace(/^\//, '') || '.');
|
|
229
|
+
}
|
|
230
|
+
return value;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (dep.startsWith('@rpath/')) {
|
|
234
|
+
const rest = dep.slice('@rpath/'.length);
|
|
235
|
+
for (const rpath of rpaths) {
|
|
236
|
+
const base = expandPrefix(rpath);
|
|
237
|
+
const candidate = join(base, rest);
|
|
238
|
+
if (exists(candidate)) return candidate;
|
|
239
|
+
}
|
|
240
|
+
// Common Node layouts when LC_RPATH is missing from a stub parse: bin/ + ../lib/.
|
|
241
|
+
for (const candidate of [
|
|
242
|
+
join(loaderDir, rest),
|
|
243
|
+
join(loaderDir, '..', 'lib', rest),
|
|
244
|
+
join(execDir, rest),
|
|
245
|
+
join(execDir, '..', 'lib', rest),
|
|
246
|
+
]) {
|
|
247
|
+
if (exists(candidate)) return candidate;
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (dep.startsWith('@loader_path') || dep.startsWith('@executable_path')) {
|
|
253
|
+
const candidate = expandPrefix(dep);
|
|
254
|
+
return exists(candidate) ? candidate : null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (dep.startsWith('/')) {
|
|
258
|
+
return exists(dep) ? dep : null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const SIBLING_DYLIB_PREFIXES = [
|
|
265
|
+
'libnode.',
|
|
266
|
+
'libuv.',
|
|
267
|
+
'libbrotli',
|
|
268
|
+
'libcares.',
|
|
269
|
+
'libnghttp',
|
|
270
|
+
'libopenssl.',
|
|
271
|
+
'libssl.',
|
|
272
|
+
'libcrypto.',
|
|
273
|
+
'libicu',
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Sibling search when otool is unavailable or incomplete — Homebrew Cellar and
|
|
278
|
+
* official tarballs keep libnode next to bin/ or under ../lib/.
|
|
279
|
+
*/
|
|
280
|
+
export function discoverSiblingNodeLibraries(nodePath, { exists = existsSync, listDir } = {}) {
|
|
281
|
+
const dir = dirname(nodePath);
|
|
282
|
+
const searchDirs = [dir, join(dir, 'lib'), join(dir, '..', 'lib')];
|
|
283
|
+
const found = [];
|
|
284
|
+
const list =
|
|
285
|
+
listDir ||
|
|
286
|
+
((path) => {
|
|
287
|
+
try {
|
|
288
|
+
return readdirSync(path);
|
|
289
|
+
} catch {
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
for (const searchDir of searchDirs) {
|
|
294
|
+
if (!exists(searchDir)) continue;
|
|
295
|
+
for (const name of list(searchDir)) {
|
|
296
|
+
if (!name.endsWith('.dylib')) continue;
|
|
297
|
+
if (!SIBLING_DYLIB_PREFIXES.some((prefix) => name.startsWith(prefix))) continue;
|
|
298
|
+
const full = join(searchDir, name);
|
|
299
|
+
if (exists(full)) found.push(full);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return found;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* BFS: every non-system Mach-O dependency of `nodePath` that must live inside the .app.
|
|
307
|
+
* Returns absolute source paths (deduped, stable order).
|
|
308
|
+
*/
|
|
309
|
+
export function collectNodeRuntimeLibraries(
|
|
310
|
+
nodePath,
|
|
311
|
+
{
|
|
312
|
+
exists = existsSync,
|
|
313
|
+
readDeps = defaultReadOtoolDeps,
|
|
314
|
+
readRpaths = defaultReadOtoolRpaths,
|
|
315
|
+
listDir,
|
|
316
|
+
} = {},
|
|
317
|
+
) {
|
|
318
|
+
const realNode = (() => {
|
|
319
|
+
try {
|
|
320
|
+
return realpathSync(nodePath);
|
|
321
|
+
} catch {
|
|
322
|
+
return nodePath;
|
|
323
|
+
}
|
|
324
|
+
})();
|
|
325
|
+
|
|
326
|
+
const queue = [realNode];
|
|
327
|
+
const seenBinaries = new Set();
|
|
328
|
+
const libraries = [];
|
|
329
|
+
const seenLibs = new Set();
|
|
330
|
+
|
|
331
|
+
const enqueueLib = (source) => {
|
|
332
|
+
let real = source;
|
|
333
|
+
try {
|
|
334
|
+
real = realpathSync(source);
|
|
335
|
+
} catch {
|
|
336
|
+
// keep source
|
|
337
|
+
}
|
|
338
|
+
if (seenLibs.has(real)) return;
|
|
339
|
+
seenLibs.add(real);
|
|
340
|
+
libraries.push(real);
|
|
341
|
+
queue.push(real);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// Seed Cellar/tarball siblings first so a missing otool still walks transitive deps.
|
|
345
|
+
for (const sibling of discoverSiblingNodeLibraries(realNode, { exists, listDir })) {
|
|
346
|
+
enqueueLib(sibling);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
while (queue.length > 0) {
|
|
350
|
+
const current = queue.shift();
|
|
351
|
+
if (!current || seenBinaries.has(current)) continue;
|
|
352
|
+
seenBinaries.add(current);
|
|
353
|
+
|
|
354
|
+
const rpaths = readRpaths(current);
|
|
355
|
+
const deps = readDeps(current);
|
|
356
|
+
for (const dep of deps) {
|
|
357
|
+
if (isMacSystemLibrary(dep)) continue;
|
|
358
|
+
// Self-id lines sometimes appear as the dylib's install name — skip when it resolves to self.
|
|
359
|
+
const resolved = resolveMachODependency(dep, {
|
|
360
|
+
binaryPath: current,
|
|
361
|
+
executablePath: realNode,
|
|
362
|
+
rpaths,
|
|
363
|
+
exists,
|
|
364
|
+
});
|
|
365
|
+
if (!resolved) continue;
|
|
366
|
+
let realResolved = resolved;
|
|
367
|
+
try {
|
|
368
|
+
realResolved = realpathSync(resolved);
|
|
369
|
+
} catch {
|
|
370
|
+
// keep
|
|
371
|
+
}
|
|
372
|
+
if (realResolved === realNode || realResolved === current) continue;
|
|
373
|
+
if (isMacSystemLibrary(realResolved)) continue;
|
|
374
|
+
enqueueLib(realResolved);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return libraries;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function defaultReadOtoolDeps(binaryPath) {
|
|
382
|
+
const result = spawnSync('otool', ['-L', binaryPath], { encoding: 'utf8' });
|
|
383
|
+
if (result.status !== 0) return [];
|
|
384
|
+
return parseOtoolDependencies(result.stdout || '');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function defaultReadOtoolRpaths(binaryPath) {
|
|
388
|
+
const result = spawnSync('otool', ['-l', binaryPath], { encoding: 'utf8' });
|
|
389
|
+
if (result.status !== 0) return [];
|
|
390
|
+
return parseOtoolRpaths(result.stdout || '');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Drop Gatekeeper quarantine on a path (best-effort; missing xattr is fine). */
|
|
394
|
+
export function clearMacQuarantine(targetPath) {
|
|
395
|
+
spawnSync('xattr', ['-dr', 'com.apple.quarantine', targetPath], { encoding: 'utf8' });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function runInstallNameTool(argv, run) {
|
|
399
|
+
// -add_rpath fails (status 1) when the rpath already exists — that is fine.
|
|
400
|
+
return run('install_name_tool', argv);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Rewrite load commands so bundled libs resolve via `@rpath/<basename>` inside Contents/lib.
|
|
405
|
+
* Best-effort: missing install_name_tool still leaves copies under Contents/lib for @rpath.
|
|
406
|
+
*/
|
|
407
|
+
export function rewriteBundledMachOLinks({
|
|
408
|
+
executablePath,
|
|
409
|
+
libDir,
|
|
410
|
+
libraryBasenames,
|
|
411
|
+
readDeps = defaultReadOtoolDeps,
|
|
412
|
+
run = (cmd, argv) => spawnSync(cmd, argv, { encoding: 'utf8' }),
|
|
413
|
+
}) {
|
|
414
|
+
const names = new Set(libraryBasenames);
|
|
415
|
+
|
|
416
|
+
runInstallNameTool(['-add_rpath', '@executable_path/../lib', executablePath], run);
|
|
417
|
+
runInstallNameTool(['-add_rpath', '@executable_path', executablePath], run);
|
|
418
|
+
|
|
419
|
+
for (const name of names) {
|
|
420
|
+
runInstallNameTool(['-id', `@rpath/${name}`, join(libDir, name)], run);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const consumers = [executablePath, ...[...names].map((name) => join(libDir, name))];
|
|
424
|
+
for (const consumer of consumers) {
|
|
425
|
+
for (const dep of readDeps(consumer)) {
|
|
426
|
+
if (isMacSystemLibrary(dep)) continue;
|
|
427
|
+
const depBase = basename(
|
|
428
|
+
dep
|
|
429
|
+
.replace(/^@rpath\//, '')
|
|
430
|
+
.replace(/^@loader_path\//, '')
|
|
431
|
+
.replace(/^@executable_path\//, ''),
|
|
432
|
+
);
|
|
433
|
+
if (!names.has(depBase)) continue;
|
|
434
|
+
const desired = `@rpath/${depBase}`;
|
|
435
|
+
if (dep === desired) continue;
|
|
436
|
+
runInstallNameTool(['-change', dep, desired, consumer], run);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
119
441
|
export function buildMacosAppInfoPlist() {
|
|
120
442
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
121
443
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -135,10 +457,23 @@ export function buildMacosAppInfoPlist() {
|
|
|
135
457
|
`;
|
|
136
458
|
}
|
|
137
459
|
|
|
138
|
-
export function buildLaunchAgentPlist({
|
|
460
|
+
export function buildLaunchAgentPlist({
|
|
461
|
+
label,
|
|
462
|
+
executable,
|
|
463
|
+
script,
|
|
464
|
+
logPath,
|
|
465
|
+
pathEnv = LAUNCH_AGENT_PATH,
|
|
466
|
+
throttleIntervalSec = LAUNCH_AGENT_THROTTLE_INTERVAL_SEC,
|
|
467
|
+
home = homedir(),
|
|
468
|
+
tmpDir = process.env.TMPDIR || '/tmp',
|
|
469
|
+
}) {
|
|
139
470
|
// ProcessType=Interactive opts the worker out of App Nap. Without it, macOS can freeze the
|
|
140
471
|
// Node poll loop while the screen is locked or idle; lastSeenAt ages out and the gateway
|
|
141
472
|
// shows Offline even though the process is still "running".
|
|
473
|
+
// Explicit PATH + HOME: launchd + the Jian Remote.app Node copy otherwise inherit a sparse
|
|
474
|
+
// env. Missing HOME makes os.homedir() / shell cwd wrong; without /bin on PATH, child tools
|
|
475
|
+
// fail. Invalid cwd surfaces as the misleading "spawn /bin/sh ENOENT" from libuv.
|
|
476
|
+
// ThrottleInterval: after an update exit, KeepAlive must not immediately fight a CLI restart.
|
|
142
477
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
143
478
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
144
479
|
<plist version="1.0"><dict>
|
|
@@ -151,9 +486,17 @@ export function buildLaunchAgentPlist({ label, executable, script, logPath }) {
|
|
|
151
486
|
<string>${xmlEscape(script)}</string>
|
|
152
487
|
<string>run</string>
|
|
153
488
|
</array>
|
|
489
|
+
<key>WorkingDirectory</key><string>${xmlEscape(home)}</string>
|
|
490
|
+
<key>EnvironmentVariables</key>
|
|
491
|
+
<dict>
|
|
492
|
+
<key>PATH</key><string>${xmlEscape(pathEnv)}</string>
|
|
493
|
+
<key>HOME</key><string>${xmlEscape(home)}</string>
|
|
494
|
+
<key>TMPDIR</key><string>${xmlEscape(tmpDir)}</string>
|
|
495
|
+
</dict>
|
|
154
496
|
<key>ProcessType</key><string>Interactive</string>
|
|
155
497
|
<key>RunAtLoad</key><true/>
|
|
156
498
|
<key>KeepAlive</key><true/>
|
|
499
|
+
<key>ThrottleInterval</key><integer>${Number(throttleIntervalSec) || 10}</integer>
|
|
157
500
|
<key>StandardOutPath</key><string>${xmlEscape(logPath)}</string>
|
|
158
501
|
<key>StandardErrorPath</key><string>${xmlEscape(logPath)}</string>
|
|
159
502
|
</dict></plist>
|
|
@@ -161,26 +504,78 @@ export function buildLaunchAgentPlist({ label, executable, script, logPath }) {
|
|
|
161
504
|
}
|
|
162
505
|
|
|
163
506
|
/**
|
|
164
|
-
* Copy the current Node binary
|
|
165
|
-
*
|
|
507
|
+
* Copy the current Node binary *and* its @rpath shared libs (libnode, ICU, …) into
|
|
508
|
+
* Jian Remote.app, then ad-hoc sign so Login Items / TCC attach to Jian — not system node.
|
|
509
|
+
* Homebrew / nvm / fnm / official pkg layouts resolve via Contents/lib (@executable_path/../lib).
|
|
166
510
|
*/
|
|
167
|
-
async function materializeMacosAppRuntime(
|
|
168
|
-
|
|
511
|
+
export async function materializeMacosAppRuntime({
|
|
512
|
+
sourceNode = process.execPath,
|
|
513
|
+
home = homedir(),
|
|
514
|
+
collectLibraries = collectNodeRuntimeLibraries,
|
|
515
|
+
} = {}) {
|
|
516
|
+
const paths = macosAppBundlePaths(home);
|
|
169
517
|
await mkdir(paths.macosDir, { recursive: true, mode: 0o755 });
|
|
518
|
+
await mkdir(paths.libDir, { recursive: true, mode: 0o755 });
|
|
170
519
|
await writeFile(paths.infoPlist, buildMacosAppInfoPlist(), { mode: 0o644 });
|
|
171
|
-
|
|
520
|
+
|
|
521
|
+
let realSource = sourceNode;
|
|
522
|
+
try {
|
|
523
|
+
realSource = realpathSync(sourceNode);
|
|
524
|
+
} catch {
|
|
525
|
+
// keep sourceNode
|
|
526
|
+
}
|
|
527
|
+
|
|
172
528
|
const temp = `${paths.executable}.${process.pid}.tmp`;
|
|
173
|
-
await copyFile(
|
|
529
|
+
await copyFile(realSource, temp);
|
|
174
530
|
await chmod(temp, 0o755);
|
|
175
531
|
await rename(temp, paths.executable);
|
|
176
|
-
|
|
532
|
+
|
|
533
|
+
// Drop stale dylibs from a previous Node major so @rpath cannot pick an old libnode.
|
|
534
|
+
try {
|
|
535
|
+
for (const name of await readdir(paths.libDir)) {
|
|
536
|
+
if (!name.endsWith('.dylib')) continue;
|
|
537
|
+
await rm(join(paths.libDir, name), { force: true });
|
|
538
|
+
}
|
|
539
|
+
} catch {
|
|
540
|
+
// lib dir may be brand new
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const librarySources = collectLibraries(realSource);
|
|
544
|
+
const libraryBasenames = [];
|
|
545
|
+
for (const libSource of librarySources) {
|
|
546
|
+
const name = basename(libSource);
|
|
547
|
+
if (!name.endsWith('.dylib')) continue;
|
|
548
|
+
const dest = join(paths.libDir, name);
|
|
549
|
+
await copyFile(libSource, dest);
|
|
550
|
+
await chmod(dest, 0o755);
|
|
551
|
+
libraryBasenames.push(name);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (libraryBasenames.length > 0) {
|
|
555
|
+
rewriteBundledMachOLinks({
|
|
556
|
+
executablePath: paths.executable,
|
|
557
|
+
libDir: paths.libDir,
|
|
558
|
+
libraryBasenames,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
clearMacQuarantine(paths.appRoot);
|
|
563
|
+
|
|
564
|
+
// Sign nested dylibs first, then the executable, then the bundle (ad-hoc / unsigned path).
|
|
565
|
+
for (const name of libraryBasenames) {
|
|
566
|
+
spawnSync(
|
|
567
|
+
'codesign',
|
|
568
|
+
['--force', '-s', '-', '--identifier', MACOS_BUNDLE_ID, join(paths.libDir, name)],
|
|
569
|
+
{ stdio: 'ignore' },
|
|
570
|
+
);
|
|
571
|
+
}
|
|
177
572
|
spawnSync('codesign', ['--force', '-s', '-', '--identifier', MACOS_BUNDLE_ID, paths.executable], {
|
|
178
573
|
stdio: 'ignore',
|
|
179
574
|
});
|
|
180
575
|
spawnSync('codesign', ['--force', '-s', '-', '--identifier', MACOS_BUNDLE_ID, paths.appRoot], {
|
|
181
576
|
stdio: 'ignore',
|
|
182
577
|
});
|
|
183
|
-
return paths;
|
|
578
|
+
return { ...paths, bundledLibraries: libraryBasenames };
|
|
184
579
|
}
|
|
185
580
|
|
|
186
581
|
export function parseRelease(version) {
|
|
@@ -243,7 +638,7 @@ export function versionsCompatible(localVersion, gatewayVersion) {
|
|
|
243
638
|
local: local.raw,
|
|
244
639
|
gateway: gateway.raw,
|
|
245
640
|
required: `${gateway.major}.${gateway.minor}`,
|
|
246
|
-
message: `This remote-control ${local.raw} is incompatible with gateway ${gateway.raw}. Jian requires the same major.minor (${gateway.major}.${gateway.minor}).
|
|
641
|
+
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`,
|
|
247
642
|
};
|
|
248
643
|
}
|
|
249
644
|
|
|
@@ -252,6 +647,50 @@ export function compatibilityError(localVersion, gatewayVersion) {
|
|
|
252
647
|
return result.ok ? null : result.message;
|
|
253
648
|
}
|
|
254
649
|
|
|
650
|
+
/**
|
|
651
|
+
* True when the worker should self-update so its PACKAGE_VERSION matches the gateway release.
|
|
652
|
+
* Covers patch bumps and major.minor skew (fleet upgrade to the gateway’s version). Dev /
|
|
653
|
+
* unversioned gateways never force a match.
|
|
654
|
+
*/
|
|
655
|
+
export function shouldMatchGatewayVersion(localVersion, gatewayVersion) {
|
|
656
|
+
if (isDevVersion(gatewayVersion)) return false;
|
|
657
|
+
const gateway = parseRelease(gatewayVersion);
|
|
658
|
+
if (!gateway) return false;
|
|
659
|
+
if (isDevVersion(localVersion)) return true;
|
|
660
|
+
const local = parseRelease(localVersion);
|
|
661
|
+
if (!local) return true;
|
|
662
|
+
return local.raw !== gateway.raw;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Soft ceiling so a failed fleet update does not KeepAlive-spin every few seconds. */
|
|
666
|
+
export function nextUpdateBackoffMs(failureCount) {
|
|
667
|
+
const n = Math.max(0, Number(failureCount) || 0);
|
|
668
|
+
// 5s, 10s, … then cap at 30 minutes (2^9 * 5s = 2560s > 30m).
|
|
669
|
+
return Math.min(30 * 60_000, 5_000 * 2 ** Math.min(n, 9));
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Reject garbage / HTML / truncated downloads before replacing ~/.config/jian-remote/jian-remote.mjs.
|
|
674
|
+
* Credentials and config.json are never touched by the updater.
|
|
675
|
+
*/
|
|
676
|
+
export function validateWorkerScriptBytes(bytes) {
|
|
677
|
+
if (!Buffer.isBuffer(bytes) && typeof bytes !== 'string') {
|
|
678
|
+
return { ok: false, message: 'Worker update was empty or unreadable.' };
|
|
679
|
+
}
|
|
680
|
+
const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
681
|
+
if (!buffer.length || buffer.length > 1_000_000) {
|
|
682
|
+
return { ok: false, message: 'Invalid worker update size.' };
|
|
683
|
+
}
|
|
684
|
+
const source = buffer.toString('utf8');
|
|
685
|
+
if (!source.includes('Jian Remote') || !/export const PACKAGE_VERSION = '[^']+'/.test(source)) {
|
|
686
|
+
return { ok: false, message: 'Worker update did not look like a Jian Remote script.' };
|
|
687
|
+
}
|
|
688
|
+
if (!source.includes('updateIfNeeded') || !source.includes('async function run(')) {
|
|
689
|
+
return { ok: false, message: 'Worker update was missing required entrypoints.' };
|
|
690
|
+
}
|
|
691
|
+
return { ok: true, version: readEmbeddedVersion(source) };
|
|
692
|
+
}
|
|
693
|
+
|
|
255
694
|
export function cappedAppend(current, chunk, cap = STDOUT_CAP) {
|
|
256
695
|
return (current + chunk.toString('utf8')).slice(0, cap);
|
|
257
696
|
}
|
|
@@ -478,19 +917,11 @@ function fetchNpmWorkerScript(version) {
|
|
|
478
917
|
}
|
|
479
918
|
}
|
|
480
919
|
|
|
481
|
-
/**
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
await writeFile(temp, sourceBytes, { mode: 0o700 });
|
|
487
|
-
await rename(temp, installedScript);
|
|
488
|
-
} else {
|
|
489
|
-
const source = fileURLToPath(import.meta.url);
|
|
490
|
-
if (source !== installedScript) await copyFile(source, installedScript);
|
|
491
|
-
}
|
|
492
|
-
if (process.platform !== 'win32') await chmod(installedScript, 0o700);
|
|
493
|
-
// Companion modules + OS helpers (computer use). Best-effort beside the worker.
|
|
920
|
+
/**
|
|
921
|
+
* Copy computer-use companions next to the installed worker. Never overwrites jian-remote.mjs
|
|
922
|
+
* (auto-update must not clobber a gateway-stamped script with an older npm copy).
|
|
923
|
+
*/
|
|
924
|
+
async function materializeCompanionModules() {
|
|
494
925
|
try {
|
|
495
926
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
496
927
|
const companions = [
|
|
@@ -513,25 +944,215 @@ async function materializeWorkerScript(sourceBytes) {
|
|
|
513
944
|
} catch {
|
|
514
945
|
// Install still succeeds; computer-use probe will explain if the helper is missing.
|
|
515
946
|
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** Copy a worker script into ~/.config so npx and global installs share one service path. */
|
|
950
|
+
async function materializeWorkerScript(sourceBytes) {
|
|
951
|
+
await mkdir(configDir, { recursive: true, mode: 0o700 });
|
|
952
|
+
if (sourceBytes) {
|
|
953
|
+
const temp = `${installedScript}.${process.pid}.tmp`;
|
|
954
|
+
await writeFile(temp, sourceBytes, { mode: 0o700 });
|
|
955
|
+
await rename(temp, installedScript);
|
|
956
|
+
} else {
|
|
957
|
+
const source = fileURLToPath(import.meta.url);
|
|
958
|
+
if (source !== installedScript) await copyFile(source, installedScript);
|
|
959
|
+
}
|
|
960
|
+
if (process.platform !== 'win32') await chmod(installedScript, 0o700);
|
|
961
|
+
await materializeCompanionModules();
|
|
516
962
|
return installedScript;
|
|
517
963
|
}
|
|
518
964
|
|
|
965
|
+
/** True when `pid` is a live process (best-effort; false on ESRCH / invalid). */
|
|
966
|
+
export function isPidAlive(pid) {
|
|
967
|
+
const n = Number(pid);
|
|
968
|
+
if (!Number.isInteger(n) || n <= 0) return false;
|
|
969
|
+
try {
|
|
970
|
+
process.kill(n, 0);
|
|
971
|
+
return true;
|
|
972
|
+
} catch {
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
export function readWorkerLockPid(lockPath = workerLockPath) {
|
|
978
|
+
try {
|
|
979
|
+
const raw = readFileSync(lockPath, 'utf8').trim();
|
|
980
|
+
const pid = Number(raw);
|
|
981
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
982
|
+
} catch {
|
|
983
|
+
return undefined;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function sleepMs(ms) {
|
|
988
|
+
const sab = new SharedArrayBuffer(4);
|
|
989
|
+
const ia = new Int32Array(sab);
|
|
990
|
+
Atomics.wait(ia, 0, 0, Math.max(0, Number(ms) || 0));
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Stop a stray worker PID (SIGTERM → wait → SIGKILL). Used by stop/restart and the run lock.
|
|
995
|
+
*/
|
|
996
|
+
export function terminateWorkerPid(
|
|
997
|
+
pid,
|
|
998
|
+
{ waitMs = 8_000, kill = process.kill.bind(process) } = {},
|
|
999
|
+
) {
|
|
1000
|
+
if (!isPidAlive(pid)) return { stopped: true, alreadyDead: true };
|
|
1001
|
+
try {
|
|
1002
|
+
kill(pid, 'SIGTERM');
|
|
1003
|
+
} catch {
|
|
1004
|
+
return { stopped: !isPidAlive(pid) };
|
|
1005
|
+
}
|
|
1006
|
+
const deadline = Date.now() + waitMs;
|
|
1007
|
+
while (Date.now() < deadline && isPidAlive(pid)) sleepMs(100);
|
|
1008
|
+
if (isPidAlive(pid)) {
|
|
1009
|
+
try {
|
|
1010
|
+
kill(pid, 'SIGKILL');
|
|
1011
|
+
} catch {
|
|
1012
|
+
// ESRCH — already gone.
|
|
1013
|
+
}
|
|
1014
|
+
sleepMs(100);
|
|
1015
|
+
}
|
|
1016
|
+
return { stopped: !isPidAlive(pid) };
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Claim the exclusive worker lock. If another live PID holds it, terminate that process first
|
|
1021
|
+
* so KeepAlive / CLI restarts never leave two pollers on the same machine.
|
|
1022
|
+
*/
|
|
1023
|
+
export async function acquireWorkerLock({
|
|
1024
|
+
lockPath = workerLockPath,
|
|
1025
|
+
pid = process.pid,
|
|
1026
|
+
maxAttempts = 8,
|
|
1027
|
+
} = {}) {
|
|
1028
|
+
await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
1029
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
1030
|
+
const existing = readWorkerLockPid(lockPath);
|
|
1031
|
+
if (existing && existing !== pid && isPidAlive(existing)) {
|
|
1032
|
+
console.error(
|
|
1033
|
+
`Another Jian Remote worker is running (pid ${existing}); stopping it before continuing.`,
|
|
1034
|
+
);
|
|
1035
|
+
terminateWorkerPid(existing);
|
|
1036
|
+
}
|
|
1037
|
+
if (existing && !isPidAlive(existing)) {
|
|
1038
|
+
try {
|
|
1039
|
+
await unlink(lockPath);
|
|
1040
|
+
} catch {
|
|
1041
|
+
// Lost the race to remove a stale lock; retry create.
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
try {
|
|
1045
|
+
writeFileSync(lockPath, `${pid}\n`, { flag: 'wx', mode: 0o600 });
|
|
1046
|
+
return { ok: true, pid };
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
if (error && error.code !== 'EEXIST') throw error;
|
|
1049
|
+
sleepMs(150);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
throw new Error(
|
|
1053
|
+
`Could not claim the Jian Remote worker lock at ${lockPath}. Stop other workers with: jian-remote stop`,
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
export async function releaseWorkerLock({ lockPath = workerLockPath, pid = process.pid } = {}) {
|
|
1058
|
+
const existing = readWorkerLockPid(lockPath);
|
|
1059
|
+
if (existing !== undefined && existing !== pid) return;
|
|
1060
|
+
try {
|
|
1061
|
+
await unlink(lockPath);
|
|
1062
|
+
} catch {
|
|
1063
|
+
// Best-effort; a stale pid file is cleared on the next acquire.
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function macosPlistPath() {
|
|
1068
|
+
return join(homedir(), 'Library', 'LaunchAgents', `${MACOS_LAUNCH_AGENT_LABEL}.plist`);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function linuxUnitPath() {
|
|
1072
|
+
return join(homedir(), '.config', 'systemd', 'user', LINUX_SERVICE_UNIT);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
export function serviceKindForPlatform(platform = process.platform) {
|
|
1076
|
+
if (platform === 'darwin') return 'LaunchAgent';
|
|
1077
|
+
if (platform === 'linux') return 'systemd --user';
|
|
1078
|
+
if (platform === 'win32') return 'Task Scheduler';
|
|
1079
|
+
return 'none';
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Fully stop the OS service and any worker still holding the pid lock.
|
|
1084
|
+
* Clear success/failure for the `jian-remote stop` CLI.
|
|
1085
|
+
*/
|
|
1086
|
+
async function stopService() {
|
|
1087
|
+
const kind = serviceKindForPlatform();
|
|
1088
|
+
if (process.platform === 'darwin') {
|
|
1089
|
+
const plistPath = macosPlistPath();
|
|
1090
|
+
const domain = `gui/${process.getuid()}`;
|
|
1091
|
+
spawnSync('launchctl', ['bootout', domain, plistPath], { stdio: 'ignore' });
|
|
1092
|
+
spawnSync('launchctl', ['bootout', `${domain}/${MACOS_LAUNCH_AGENT_LABEL}`], {
|
|
1093
|
+
stdio: 'ignore',
|
|
1094
|
+
});
|
|
1095
|
+
} else if (process.platform === 'linux') {
|
|
1096
|
+
spawnSync('systemctl', ['--user', 'stop', LINUX_SERVICE_UNIT], { stdio: 'ignore' });
|
|
1097
|
+
} else if (process.platform === 'win32') {
|
|
1098
|
+
spawnSync('schtasks.exe', ['/End', '/TN', WINDOWS_TASK_NAME], {
|
|
1099
|
+
stdio: 'ignore',
|
|
1100
|
+
windowsHide: true,
|
|
1101
|
+
});
|
|
1102
|
+
} else {
|
|
1103
|
+
throw new Error(`Service stop is unsupported on ${process.platform}.`);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const locked = readWorkerLockPid();
|
|
1107
|
+
if (locked) {
|
|
1108
|
+
const result = terminateWorkerPid(locked);
|
|
1109
|
+
if (!result.stopped) {
|
|
1110
|
+
throw new Error(
|
|
1111
|
+
`Stopped the ${kind} job but worker pid ${locked} is still running. Try again or kill it manually.`,
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
await releaseWorkerLock({ pid: locked ?? process.pid });
|
|
1116
|
+
// Also clear a lock we never owned (stop from a CLI process).
|
|
1117
|
+
try {
|
|
1118
|
+
await unlink(workerLockPath);
|
|
1119
|
+
} catch {
|
|
1120
|
+
// absent
|
|
1121
|
+
}
|
|
1122
|
+
return { service: kind, stopped: true };
|
|
1123
|
+
}
|
|
1124
|
+
|
|
519
1125
|
/**
|
|
520
|
-
* Register
|
|
1126
|
+
* Register (if needed) and start exactly one worker. Always stops any previous instance first.
|
|
521
1127
|
* On macOS, ProgramArguments[0] is the Jian Remote.app Node copy — not system node — so
|
|
522
1128
|
* Login Items / TCC show "Jian Remote".
|
|
523
1129
|
*/
|
|
524
|
-
async function
|
|
1130
|
+
async function startService(script = installedScript) {
|
|
1131
|
+
try {
|
|
1132
|
+
await stopService();
|
|
1133
|
+
} catch (error) {
|
|
1134
|
+
// First install / never registered: stop may complain on exotic platforms only.
|
|
1135
|
+
// A live pid that will not die must still fail the start.
|
|
1136
|
+
if (error instanceof Error && /still running/i.test(error.message)) throw error;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
try {
|
|
1140
|
+
await readFile(script);
|
|
1141
|
+
} catch {
|
|
1142
|
+
throw new Error(
|
|
1143
|
+
`No installed worker at ${script}. Run: jian-remote install --gateway … --machine … --token …`,
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
525
1147
|
if (process.platform === 'darwin') {
|
|
526
1148
|
const launchAgents = join(homedir(), 'Library', 'LaunchAgents');
|
|
527
|
-
const plistPath =
|
|
528
|
-
const label = 'com.jian.remote';
|
|
1149
|
+
const plistPath = macosPlistPath();
|
|
529
1150
|
const app = await materializeMacosAppRuntime();
|
|
530
1151
|
await mkdir(launchAgents, { recursive: true, mode: 0o700 });
|
|
531
1152
|
await writeFile(
|
|
532
1153
|
plistPath,
|
|
533
1154
|
buildLaunchAgentPlist({
|
|
534
|
-
label,
|
|
1155
|
+
label: MACOS_LAUNCH_AGENT_LABEL,
|
|
535
1156
|
executable: app.executable,
|
|
536
1157
|
script,
|
|
537
1158
|
logPath: join(configDir, 'worker.log'),
|
|
@@ -539,7 +1160,9 @@ async function startOrRestartService(script) {
|
|
|
539
1160
|
{ mode: 0o600 },
|
|
540
1161
|
);
|
|
541
1162
|
const domain = `gui/${process.getuid()}`;
|
|
1163
|
+
// bootout again after writing the plist so KeepAlive cannot resurrect the old definition.
|
|
542
1164
|
spawnSync('launchctl', ['bootout', domain, plistPath], { stdio: 'ignore' });
|
|
1165
|
+
sleepMs(400);
|
|
543
1166
|
const loaded = spawnSync('launchctl', ['bootstrap', domain, plistPath], { stdio: 'inherit' });
|
|
544
1167
|
if (loaded.status !== 0) throw new Error('Could not start Jian Remote with launchd');
|
|
545
1168
|
return {
|
|
@@ -552,24 +1175,16 @@ async function startOrRestartService(script) {
|
|
|
552
1175
|
await mkdir(unitDir, { recursive: true, mode: 0o700 });
|
|
553
1176
|
const quote = (value) => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
554
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`;
|
|
555
|
-
await writeFile(
|
|
556
|
-
for (const command of [
|
|
557
|
-
['daemon-reload'],
|
|
558
|
-
['enable', '--now', 'jian-remote.service'],
|
|
559
|
-
['restart', 'jian-remote.service'],
|
|
560
|
-
]) {
|
|
1178
|
+
await writeFile(linuxUnitPath(), unit, { mode: 0o600 });
|
|
1179
|
+
for (const command of [['daemon-reload'], ['enable', '--now', LINUX_SERVICE_UNIT]]) {
|
|
561
1180
|
const result = spawnSync('systemctl', ['--user', ...command], { stdio: 'inherit' });
|
|
562
1181
|
if (result.status !== 0)
|
|
563
1182
|
throw new Error('Could not install/start Jian Remote with systemd --user');
|
|
564
1183
|
}
|
|
565
|
-
return { service: 'systemd --user', detail:
|
|
1184
|
+
return { service: 'systemd --user', detail: LINUX_SERVICE_UNIT };
|
|
566
1185
|
}
|
|
567
1186
|
if (process.platform === 'win32') {
|
|
568
1187
|
const action = `"${process.execPath}" "${script}" run`;
|
|
569
|
-
spawnSync('schtasks.exe', ['/End', '/TN', 'Jian Remote'], {
|
|
570
|
-
stdio: 'ignore',
|
|
571
|
-
windowsHide: true,
|
|
572
|
-
});
|
|
573
1188
|
runChecked(
|
|
574
1189
|
'schtasks.exe',
|
|
575
1190
|
[
|
|
@@ -577,7 +1192,7 @@ async function startOrRestartService(script) {
|
|
|
577
1192
|
'/SC',
|
|
578
1193
|
'ONLOGON',
|
|
579
1194
|
'/TN',
|
|
580
|
-
|
|
1195
|
+
WINDOWS_TASK_NAME,
|
|
581
1196
|
'/TR',
|
|
582
1197
|
action,
|
|
583
1198
|
'/F',
|
|
@@ -589,15 +1204,49 @@ async function startOrRestartService(script) {
|
|
|
589
1204
|
);
|
|
590
1205
|
runChecked(
|
|
591
1206
|
'schtasks.exe',
|
|
592
|
-
['/Run', '/TN',
|
|
1207
|
+
['/Run', '/TN', WINDOWS_TASK_NAME],
|
|
593
1208
|
'Could not start the Jian Remote scheduled task',
|
|
594
1209
|
);
|
|
595
|
-
return { service: 'Task Scheduler', detail:
|
|
1210
|
+
return { service: 'Task Scheduler', detail: `${WINDOWS_TASK_NAME} (ONLOGON)` };
|
|
596
1211
|
}
|
|
597
1212
|
printInfo(`Service auto-install is unsupported on ${process.platform}. Run: node ${script} run`);
|
|
598
1213
|
return { service: 'none', detail: process.platform };
|
|
599
1214
|
}
|
|
600
1215
|
|
|
1216
|
+
async function restartService(script = installedScript) {
|
|
1217
|
+
// stopService is invoked inside startService; call stop first for clear CLI semantics / logs.
|
|
1218
|
+
await stopService().catch(() => undefined);
|
|
1219
|
+
return startService(script);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/** @deprecated use restartService — kept as the install/configure/update entry name. */
|
|
1223
|
+
async function startOrRestartService(script) {
|
|
1224
|
+
return restartService(script);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
async function printVersionAndExit() {
|
|
1228
|
+
console.log(PACKAGE_VERSION);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
async function cliStop() {
|
|
1232
|
+
const result = await stopService();
|
|
1233
|
+
printSuccess('Stopped Jian Remote', [['Service', `${result.service} stopped`]]);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
async function cliStart() {
|
|
1237
|
+
const started = await startService(installedScript);
|
|
1238
|
+
const facts = [['Service', `${started.service} started`]];
|
|
1239
|
+
if (started.detail) facts.push(['Identity', started.detail]);
|
|
1240
|
+
printSuccess('Started Jian Remote', facts);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
async function cliRestart() {
|
|
1244
|
+
const started = await restartService(installedScript);
|
|
1245
|
+
const facts = [['Service', `${started.service} restarted`]];
|
|
1246
|
+
if (started.detail) facts.push(['Identity', started.detail]);
|
|
1247
|
+
printSuccess('Restarted Jian Remote', facts);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
601
1250
|
async function install() {
|
|
602
1251
|
const gateway = option('--gateway');
|
|
603
1252
|
const machineId = option('--machine');
|
|
@@ -720,20 +1369,40 @@ async function update() {
|
|
|
720
1369
|
let scriptBytes;
|
|
721
1370
|
let installedVersion = PACKAGE_VERSION;
|
|
722
1371
|
// When this process already matches the target (typical: npx @jian-ai/remote-control@X update),
|
|
723
|
-
// copy ourselves.
|
|
1372
|
+
// copy ourselves. Require an **exact** semver match — same major.minor alone used to let a
|
|
1373
|
+
// global 0.8.1 CLI rewrite the installed script while the gateway was already on 0.8.2,
|
|
1374
|
+
// fighting auto-update and flip-flopping two pollers.
|
|
724
1375
|
const selfMatchesTarget =
|
|
725
1376
|
target === 'latest'
|
|
726
1377
|
? !isDevVersion(PACKAGE_VERSION)
|
|
727
|
-
: PACKAGE_VERSION === target.replace(/^v/, '')
|
|
728
|
-
versionsCompatible(PACKAGE_VERSION, target).ok;
|
|
1378
|
+
: PACKAGE_VERSION === target.replace(/^v/, '');
|
|
729
1379
|
|
|
730
1380
|
if (selfMatchesTarget && !isDevVersion(PACKAGE_VERSION)) {
|
|
731
1381
|
scriptBytes = await readFile(fileURLToPath(import.meta.url));
|
|
732
1382
|
installedVersion = PACKAGE_VERSION;
|
|
733
1383
|
} else {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
1384
|
+
// Prefer the gateway’s stamped script when targeting that release — works when npm has not
|
|
1385
|
+
// published the version yet. Fall back to npm pack for pins / latest.
|
|
1386
|
+
const wantGatewayScript =
|
|
1387
|
+
gatewayVersion &&
|
|
1388
|
+
target !== 'latest' &&
|
|
1389
|
+
parseRelease(target)?.raw === parseRelease(gatewayVersion)?.raw;
|
|
1390
|
+
if (wantGatewayScript) {
|
|
1391
|
+
try {
|
|
1392
|
+
printInfo('Downloading worker script from the gateway…');
|
|
1393
|
+
scriptBytes = await fetchGatewayWorkerScript(config.gateway);
|
|
1394
|
+
installedVersion = readEmbeddedVersion(scriptBytes.toString('utf8'));
|
|
1395
|
+
} catch (error) {
|
|
1396
|
+
printInfo(
|
|
1397
|
+
`Gateway script download failed (${error instanceof Error ? error.message : error}); trying npm…`,
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
if (!scriptBytes) {
|
|
1402
|
+
printInfo(`Downloading @jian-ai/remote-control@${target}…`);
|
|
1403
|
+
scriptBytes = fetchNpmWorkerScript(target);
|
|
1404
|
+
installedVersion = readEmbeddedVersion(scriptBytes.toString('utf8'));
|
|
1405
|
+
}
|
|
737
1406
|
}
|
|
738
1407
|
|
|
739
1408
|
const error = compatibilityError(installedVersion, gatewayVersion);
|
|
@@ -763,27 +1432,125 @@ async function update() {
|
|
|
763
1432
|
printSuccess('Updated Jian Remote', facts);
|
|
764
1433
|
}
|
|
765
1434
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
1435
|
+
/** Absolute shell path for remote commands — never rely on PATH for the shell itself. */
|
|
1436
|
+
export function shellCommandForPlatform(platform = process.platform) {
|
|
1437
|
+
if (platform === 'win32') return 'powershell.exe';
|
|
1438
|
+
if (existsSync('/bin/sh')) return '/bin/sh';
|
|
1439
|
+
if (existsSync('/bin/bash')) return '/bin/bash';
|
|
1440
|
+
if (existsSync('/usr/bin/sh')) return '/usr/bin/sh';
|
|
1441
|
+
return '/bin/sh';
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* Resolve the working directory for a remote shell task.
|
|
1446
|
+
* libuv reports a missing cwd as `spawn /bin/sh ENOENT` even when the shell exists — Computer
|
|
1447
|
+
* Use kept working (no cwd) while run_remote_command failed. Fall back to $HOME when the
|
|
1448
|
+
* requested path is missing or not a directory, and surface an English note in stderr.
|
|
1449
|
+
*/
|
|
1450
|
+
export function resolveShellCwd(
|
|
1451
|
+
requested,
|
|
1452
|
+
{ home = homedir(), exists = existsSync, stat = statSync } = {},
|
|
1453
|
+
) {
|
|
1454
|
+
const fallbackCandidates = [home, process.env.TMPDIR, '/tmp', '/'].filter(Boolean);
|
|
1455
|
+
const fallback =
|
|
1456
|
+
fallbackCandidates.find((path) => {
|
|
1457
|
+
try {
|
|
1458
|
+
return exists(path) && stat(path).isDirectory();
|
|
1459
|
+
} catch {
|
|
1460
|
+
return false;
|
|
1461
|
+
}
|
|
1462
|
+
}) || '/';
|
|
1463
|
+
|
|
1464
|
+
if (requested == null || requested === '') {
|
|
1465
|
+
return { cwd: fallback, note: undefined, usedFallback: false };
|
|
1466
|
+
}
|
|
1467
|
+
const target = String(requested).trim();
|
|
1468
|
+
if (!target) {
|
|
1469
|
+
return { cwd: fallback, note: undefined, usedFallback: false };
|
|
1470
|
+
}
|
|
1471
|
+
try {
|
|
1472
|
+
if (!exists(target)) {
|
|
1473
|
+
return {
|
|
1474
|
+
cwd: fallback,
|
|
1475
|
+
note: `Requested cwd ${target} does not exist on this computer; using ${fallback}.`,
|
|
1476
|
+
usedFallback: true,
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
if (!stat(target).isDirectory()) {
|
|
1480
|
+
return {
|
|
1481
|
+
cwd: fallback,
|
|
1482
|
+
note: `Requested cwd ${target} is not a directory; using ${fallback}.`,
|
|
1483
|
+
usedFallback: true,
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
return { cwd: target, note: undefined, usedFallback: false };
|
|
1487
|
+
} catch {
|
|
1488
|
+
return {
|
|
1489
|
+
cwd: fallback,
|
|
1490
|
+
note: `Requested cwd ${target} is not usable; using ${fallback}.`,
|
|
1491
|
+
usedFallback: true,
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/** Env for shell / helper children: always a usable PATH and HOME. */
|
|
1497
|
+
export function workerChildEnv(baseEnv = process.env) {
|
|
1498
|
+
const pathKey = process.platform === 'win32' ? 'Path' : 'PATH';
|
|
1499
|
+
const current = baseEnv[pathKey] ?? baseEnv.PATH ?? '';
|
|
1500
|
+
const prefix = process.platform === 'win32' ? '' : LAUNCH_AGENT_PATH;
|
|
1501
|
+
const merged = prefix ? `${prefix}${current ? `:${current}` : ''}` : current || LAUNCH_AGENT_PATH;
|
|
1502
|
+
const home = baseEnv.HOME || baseEnv.USERPROFILE || homedir();
|
|
1503
|
+
return {
|
|
1504
|
+
...baseEnv,
|
|
1505
|
+
[pathKey]: merged,
|
|
1506
|
+
PATH: merged,
|
|
1507
|
+
HOME: home,
|
|
1508
|
+
...(process.platform === 'win32' ? { USERPROFILE: home } : {}),
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* Explain spawn failures. Node’s `spawn /bin/sh ENOENT` usually means the **cwd** was missing,
|
|
1514
|
+
* not that /bin/sh vanished — rewrite so operators and agents can act on it.
|
|
1515
|
+
*/
|
|
1516
|
+
export function formatShellSpawnError(error, { shell, cwd } = {}) {
|
|
1517
|
+
const code = error && typeof error === 'object' ? error.code : undefined;
|
|
1518
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1519
|
+
if (code === 'ENOENT' || /\bENOENT\b/.test(message)) {
|
|
1520
|
+
const shellMissing = shell && !existsSync(shell);
|
|
1521
|
+
if (shellMissing) {
|
|
1522
|
+
return `Could not find the shell executable ${shell}. Install a POSIX shell or reinstall Jian Remote.`;
|
|
1523
|
+
}
|
|
1524
|
+
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).`;
|
|
1525
|
+
}
|
|
1526
|
+
return message;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* Run a remote shell task. Exported for behavior tests (cwd fallback, absolute shell, PATH).
|
|
1531
|
+
*/
|
|
1532
|
+
export function executeShellTask(task, { spawnImpl = spawn, platform = process.platform } = {}) {
|
|
1533
|
+
return new Promise((resolveResult) => {
|
|
1534
|
+
const shell = shellCommandForPlatform(platform);
|
|
1535
|
+
const resolved = resolveShellCwd(task.cwd);
|
|
769
1536
|
const commandArgs =
|
|
770
|
-
|
|
1537
|
+
platform === 'win32'
|
|
771
1538
|
? ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', task.command]
|
|
772
1539
|
: ['-lc', task.command];
|
|
773
|
-
const child =
|
|
774
|
-
cwd:
|
|
775
|
-
env:
|
|
1540
|
+
const child = spawnImpl(shell, commandArgs, {
|
|
1541
|
+
cwd: resolved.cwd,
|
|
1542
|
+
env: workerChildEnv(),
|
|
776
1543
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
777
1544
|
});
|
|
778
1545
|
let stdout = '';
|
|
779
|
-
let stderr = '';
|
|
1546
|
+
let stderr = resolved.note ? `${resolved.note}\n` : '';
|
|
780
1547
|
let stdoutSeen = 0;
|
|
781
1548
|
let timedOut = false;
|
|
782
1549
|
const timeout = setTimeout(() => {
|
|
783
1550
|
timedOut = true;
|
|
784
1551
|
child.kill('SIGTERM');
|
|
785
1552
|
setTimeout(() => {
|
|
786
|
-
if (
|
|
1553
|
+
if (platform === 'win32' && child.pid) {
|
|
787
1554
|
spawnSync('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
|
|
788
1555
|
stdio: 'ignore',
|
|
789
1556
|
windowsHide: true,
|
|
@@ -793,11 +1560,11 @@ async function executeShell(task) {
|
|
|
793
1560
|
}
|
|
794
1561
|
}, 2000).unref();
|
|
795
1562
|
}, task.timeoutMs);
|
|
796
|
-
child.stdout
|
|
1563
|
+
child.stdout?.on('data', (chunk) => {
|
|
797
1564
|
stdoutSeen += chunk.toString('utf8').length;
|
|
798
1565
|
stdout = cappedAppend(stdout, chunk, STDOUT_CAP);
|
|
799
1566
|
});
|
|
800
|
-
child.stderr
|
|
1567
|
+
child.stderr?.on('data', (chunk) => {
|
|
801
1568
|
stderr = cappedAppend(stderr, chunk, STDERR_CAP);
|
|
802
1569
|
});
|
|
803
1570
|
child.on('error', (error) => {
|
|
@@ -806,13 +1573,16 @@ async function executeShell(task) {
|
|
|
806
1573
|
taskId: task.id,
|
|
807
1574
|
exitCode: null,
|
|
808
1575
|
stdout,
|
|
809
|
-
stderr: cappedAppend(
|
|
1576
|
+
stderr: cappedAppend(
|
|
1577
|
+
stderr,
|
|
1578
|
+
formatShellSpawnError(error, { shell, cwd: resolved.cwd }),
|
|
1579
|
+
STDERR_CAP,
|
|
1580
|
+
),
|
|
810
1581
|
timedOut,
|
|
811
1582
|
});
|
|
812
1583
|
});
|
|
813
1584
|
child.on('close', (code) => {
|
|
814
1585
|
clearTimeout(timeout);
|
|
815
|
-
// Mark silent stdout cuts so screenshot tools can reject grey/truncated JPEGs.
|
|
816
1586
|
if (stdoutSeen > STDOUT_CAP) {
|
|
817
1587
|
stderr = cappedAppend(
|
|
818
1588
|
stderr,
|
|
@@ -825,6 +1595,10 @@ async function executeShell(task) {
|
|
|
825
1595
|
});
|
|
826
1596
|
}
|
|
827
1597
|
|
|
1598
|
+
async function executeShell(task) {
|
|
1599
|
+
return executeShellTask(task);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
828
1602
|
async function execute(task) {
|
|
829
1603
|
if (task.kind === 'computer') {
|
|
830
1604
|
const { executeComputerTask } = await import('./computer.mjs');
|
|
@@ -833,8 +1607,112 @@ async function execute(task) {
|
|
|
833
1607
|
return executeShell(task);
|
|
834
1608
|
}
|
|
835
1609
|
|
|
1610
|
+
async function readUpdateState() {
|
|
1611
|
+
try {
|
|
1612
|
+
const raw = JSON.parse(await readFile(updateStatePath, 'utf8'));
|
|
1613
|
+
return {
|
|
1614
|
+
failures: Number(raw?.failures) || 0,
|
|
1615
|
+
lastError: typeof raw?.lastError === 'string' ? raw.lastError : undefined,
|
|
1616
|
+
lastFailureAt: typeof raw?.lastFailureAt === 'string' ? raw.lastFailureAt : undefined,
|
|
1617
|
+
};
|
|
1618
|
+
} catch {
|
|
1619
|
+
return { failures: 0 };
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
async function clearUpdateState() {
|
|
1624
|
+
try {
|
|
1625
|
+
rmSync(updateStatePath, { force: true });
|
|
1626
|
+
} catch {
|
|
1627
|
+
// Best-effort; a stale state file only delays the next retry.
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
async function recordUpdateFailure(error) {
|
|
1632
|
+
const previous = await readUpdateState();
|
|
1633
|
+
const failures = previous.failures + 1;
|
|
1634
|
+
const lastError = error instanceof Error ? error.message : String(error);
|
|
1635
|
+
await mkdir(configDir, { recursive: true, mode: 0o700 });
|
|
1636
|
+
await writeFile(
|
|
1637
|
+
updateStatePath,
|
|
1638
|
+
`${JSON.stringify(
|
|
1639
|
+
{
|
|
1640
|
+
failures,
|
|
1641
|
+
lastError,
|
|
1642
|
+
lastFailureAt: new Date().toISOString(),
|
|
1643
|
+
},
|
|
1644
|
+
null,
|
|
1645
|
+
2,
|
|
1646
|
+
)}\n`,
|
|
1647
|
+
{ mode: 0o600 },
|
|
1648
|
+
);
|
|
1649
|
+
return { failures, lastError, backoffMs: nextUpdateBackoffMs(failures) };
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* True when this process’s PACKAGE_VERSION no longer matches the installed script on disk.
|
|
1654
|
+
* A surviving poller after another worker already applied an update must exit so KeepAlive
|
|
1655
|
+
* loads the new file — ETag 304 alone is not enough (disk matches gateway, memory is stale).
|
|
1656
|
+
*/
|
|
1657
|
+
export function isStaleInstalledWorker(processVersion, installedSource) {
|
|
1658
|
+
if (isDevVersion(processVersion)) return false;
|
|
1659
|
+
if (typeof installedSource !== 'string' || !installedSource) return false;
|
|
1660
|
+
const diskVersion = readEmbeddedVersion(installedSource);
|
|
1661
|
+
if (!diskVersion || diskVersion === 'unknown' || isDevVersion(diskVersion)) return false;
|
|
1662
|
+
return diskVersion !== processVersion;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/**
|
|
1666
|
+
* Atomically replace the installed worker script and exit so LaunchAgent / systemd KeepAlive
|
|
1667
|
+
* (or a detached Windows child) load the new code. Never touches config.json.
|
|
1668
|
+
* Does not call startOrRestartService — that would race KeepAlive and leave two pollers.
|
|
1669
|
+
*/
|
|
1670
|
+
async function applyWorkerScriptAndRestart(updated, { sourceLabel }) {
|
|
1671
|
+
const checked = validateWorkerScriptBytes(updated);
|
|
1672
|
+
if (!checked.ok) throw new Error(checked.message);
|
|
1673
|
+
// Same bytes already on disk and already loaded — do not thrash restart loops when npm and
|
|
1674
|
+
// the gateway script disagree about packaging but stamp the same version.
|
|
1675
|
+
try {
|
|
1676
|
+
const existing = await readFile(installedScript);
|
|
1677
|
+
if (
|
|
1678
|
+
existing.equals(Buffer.isBuffer(updated) ? updated : Buffer.from(updated)) &&
|
|
1679
|
+
checked.version === PACKAGE_VERSION
|
|
1680
|
+
) {
|
|
1681
|
+
await clearUpdateState();
|
|
1682
|
+
return false;
|
|
1683
|
+
}
|
|
1684
|
+
} catch {
|
|
1685
|
+
// No installed script yet — fall through and write.
|
|
1686
|
+
}
|
|
1687
|
+
await mkdir(configDir, { recursive: true, mode: 0o700 });
|
|
1688
|
+
const temp = `${installedScript}.${process.pid}.tmp`;
|
|
1689
|
+
await writeFile(temp, updated, { mode: 0o700 });
|
|
1690
|
+
await rename(temp, installedScript);
|
|
1691
|
+
if (process.platform !== 'win32') await chmod(installedScript, 0o700);
|
|
1692
|
+
// Best-effort companions from this process’s package (gateway /update is main script only).
|
|
1693
|
+
await materializeCompanionModules();
|
|
1694
|
+
await clearUpdateState();
|
|
1695
|
+
console.log(`Worker updated to ${checked.version} from ${sourceLabel}; restarting to apply it.`);
|
|
1696
|
+
await releaseWorkerLock();
|
|
1697
|
+
if (process.platform === 'win32') {
|
|
1698
|
+
// Task Scheduler has no KeepAlive; spawn the new script then exit this process.
|
|
1699
|
+
// Only one detached child — do not also leave this process polling.
|
|
1700
|
+
const child = spawn(process.execPath, [installedScript, 'run'], {
|
|
1701
|
+
detached: true,
|
|
1702
|
+
stdio: 'ignore',
|
|
1703
|
+
windowsHide: true,
|
|
1704
|
+
});
|
|
1705
|
+
child.unref();
|
|
1706
|
+
}
|
|
1707
|
+
// macOS LaunchAgent KeepAlive / Linux systemd Restart=always reload this path.
|
|
1708
|
+
process.exit(0);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
/**
|
|
1712
|
+
* Pull the gateway-served worker (same bytes as /jian-remote.mjs, auth’d). Prefer this over npm
|
|
1713
|
+
* for fleets that can reach the gateway but not the registry.
|
|
1714
|
+
*/
|
|
836
1715
|
async function updateIfNeeded(base, headers) {
|
|
837
|
-
// Prefer the installed copy under ~/.config so updates land where the service points.
|
|
838
1716
|
const script = installedScript;
|
|
839
1717
|
let local;
|
|
840
1718
|
try {
|
|
@@ -842,30 +1720,170 @@ async function updateIfNeeded(base, headers) {
|
|
|
842
1720
|
} catch {
|
|
843
1721
|
local = await readFile(fileURLToPath(import.meta.url));
|
|
844
1722
|
}
|
|
1723
|
+
if (isStaleInstalledWorker(PACKAGE_VERSION, local.toString('utf8'))) {
|
|
1724
|
+
console.log(
|
|
1725
|
+
`Worker process ${PACKAGE_VERSION} is stale (installed script is newer); restarting to load it.`,
|
|
1726
|
+
);
|
|
1727
|
+
await releaseWorkerLock();
|
|
1728
|
+
process.exit(0);
|
|
1729
|
+
}
|
|
845
1730
|
const etag = `"${createHash('sha256').update(local).digest('hex')}"`;
|
|
846
1731
|
const response = await fetch(`${base}/update`, {
|
|
847
1732
|
headers: { ...headers, 'if-none-match': etag },
|
|
848
1733
|
redirect: 'error',
|
|
849
1734
|
signal: AbortSignal.timeout(15_000),
|
|
850
1735
|
});
|
|
851
|
-
if (response.status === 304)
|
|
1736
|
+
if (response.status === 304) {
|
|
1737
|
+
// Disk matches gateway. If this process is still the wrong release, exit to reload disk
|
|
1738
|
+
// (second poller that survived an earlier update).
|
|
1739
|
+
return false;
|
|
1740
|
+
}
|
|
852
1741
|
if (!response.ok) throw new Error(`worker update check failed: HTTP ${response.status}`);
|
|
853
1742
|
const updated = Buffer.from(await response.arrayBuffer());
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
1743
|
+
const applied = await applyWorkerScriptAndRestart(updated, { sourceLabel: 'the gateway' });
|
|
1744
|
+
// applyWorkerScriptAndRestart exits on a real replace; false means already current.
|
|
1745
|
+
return applied !== false;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/** Public /jian-remote.mjs — same stamped bytes as authenticated /update, no worker token. */
|
|
1749
|
+
async function fetchGatewayWorkerScript(gatewayBase, fetcher = fetch) {
|
|
1750
|
+
const response = await fetcher(`${gatewayBase.replace(/\/$/, '')}/jian-remote.mjs`, {
|
|
1751
|
+
redirect: 'error',
|
|
1752
|
+
signal: AbortSignal.timeout(30_000),
|
|
1753
|
+
});
|
|
1754
|
+
if (!response.ok) {
|
|
1755
|
+
throw new Error(`Could not download gateway worker script: HTTP ${response.status}`);
|
|
867
1756
|
}
|
|
868
|
-
|
|
1757
|
+
return Buffer.from(await response.arrayBuffer());
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
/**
|
|
1761
|
+
* Fallback when authenticated /update did not leave disk on the gateway release.
|
|
1762
|
+
* Prefer public /jian-remote.mjs (works when npm is not promoted yet); then npm pack.
|
|
1763
|
+
*/
|
|
1764
|
+
async function updateFromNpm(gatewayVersion, gatewayBase) {
|
|
1765
|
+
const target = parseRelease(gatewayVersion)?.raw;
|
|
1766
|
+
if (!target)
|
|
1767
|
+
throw new Error('Cannot download a worker update without a gateway release version.');
|
|
1768
|
+
if (gatewayBase) {
|
|
1769
|
+
try {
|
|
1770
|
+
console.log(`Downloading worker script from the gateway (/jian-remote.mjs)…`);
|
|
1771
|
+
const fromGateway = await fetchGatewayWorkerScript(gatewayBase);
|
|
1772
|
+
const applied = await applyWorkerScriptAndRestart(fromGateway, {
|
|
1773
|
+
sourceLabel: 'the gateway (/jian-remote.mjs)',
|
|
1774
|
+
});
|
|
1775
|
+
if (applied !== false) return true;
|
|
1776
|
+
} catch (error) {
|
|
1777
|
+
console.error(
|
|
1778
|
+
`Gateway script download failed (${error instanceof Error ? error.message : error}); trying npm…`,
|
|
1779
|
+
);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
console.log(`Downloading @jian-ai/remote-control@${target} from npm…`);
|
|
1783
|
+
const scriptBytes = fetchNpmWorkerScript(target);
|
|
1784
|
+
const applied = await applyWorkerScriptAndRestart(scriptBytes, {
|
|
1785
|
+
sourceLabel: `npm @${target}`,
|
|
1786
|
+
});
|
|
1787
|
+
return applied !== false;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/**
|
|
1791
|
+
* On connect and every few minutes: match the gateway’s worker script. Same major.minor patch
|
|
1792
|
+
* bumps use the authenticated /update ETag path; major.minor skew also self-updates to the
|
|
1793
|
+
* gateway version (gateway script first, npm fallback) instead of refusing forever.
|
|
1794
|
+
*
|
|
1795
|
+
* Prefer the gateway script. Only hit npm when the gateway path did not leave the **installed**
|
|
1796
|
+
* script on the gateway release — never rewrite a correct gateway install with an older npm
|
|
1797
|
+
* tarball (staged-but-unpublished packages caused 0.8.1↔0.8.2 thrash).
|
|
1798
|
+
*/
|
|
1799
|
+
async function reconcileWorkerWithGateway(config, base, headers) {
|
|
1800
|
+
const gatewayVersion = await fetchGatewayVersion(config.gateway);
|
|
1801
|
+
const gatewayRelease = parseRelease(gatewayVersion)?.raw;
|
|
1802
|
+
const compat = versionsCompatible(PACKAGE_VERSION, gatewayVersion);
|
|
1803
|
+
const mustMatch = shouldMatchGatewayVersion(PACKAGE_VERSION, gatewayVersion);
|
|
1804
|
+
|
|
1805
|
+
let installedVersion;
|
|
1806
|
+
try {
|
|
1807
|
+
installedVersion = readEmbeddedVersion((await readFile(installedScript)).toString('utf8'));
|
|
1808
|
+
} catch {
|
|
1809
|
+
installedVersion = undefined;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
let gatewayUpdateError;
|
|
1813
|
+
try {
|
|
1814
|
+
const changed = await updateIfNeeded(base, headers);
|
|
1815
|
+
// Re-read after a no-op check; updateIfNeeded exits on apply.
|
|
1816
|
+
try {
|
|
1817
|
+
installedVersion = readEmbeddedVersion((await readFile(installedScript)).toString('utf8'));
|
|
1818
|
+
} catch {
|
|
1819
|
+
// keep previous
|
|
1820
|
+
}
|
|
1821
|
+
// 304 and still wrong version → gateway bytes match disk but this process is stale, or the
|
|
1822
|
+
// gateway could not stamp a newer script. Fall through to npm only when disk is wrong too.
|
|
1823
|
+
if (!changed && mustMatch) {
|
|
1824
|
+
if (gatewayRelease && installedVersion === gatewayRelease) {
|
|
1825
|
+
console.log(
|
|
1826
|
+
`Installed worker is ${installedVersion} but this process is ${PACKAGE_VERSION}; restarting to load the installed script.`,
|
|
1827
|
+
);
|
|
1828
|
+
await releaseWorkerLock();
|
|
1829
|
+
process.exit(0);
|
|
1830
|
+
}
|
|
1831
|
+
gatewayUpdateError = new Error(
|
|
1832
|
+
`Gateway worker script is unchanged but this process is ${PACKAGE_VERSION} (gateway ${gatewayVersion}).`,
|
|
1833
|
+
);
|
|
1834
|
+
}
|
|
1835
|
+
} catch (error) {
|
|
1836
|
+
gatewayUpdateError = error instanceof Error ? error : new Error(String(error));
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
const diskAlreadyMatches = Boolean(gatewayRelease) && installedVersion === gatewayRelease;
|
|
1840
|
+
|
|
1841
|
+
if (gatewayUpdateError && (mustMatch || !compat.ok) && !diskAlreadyMatches) {
|
|
1842
|
+
console.error(
|
|
1843
|
+
`Gateway self-update did not apply (${gatewayUpdateError.message}); trying npm for ${gatewayVersion}…`,
|
|
1844
|
+
);
|
|
1845
|
+
try {
|
|
1846
|
+
await updateFromNpm(gatewayVersion, config.gateway);
|
|
1847
|
+
} catch (npmError) {
|
|
1848
|
+
const recorded = await recordUpdateFailure(npmError);
|
|
1849
|
+
console.error(
|
|
1850
|
+
`Automatic worker update failed (${recorded.lastError}). Will retry after ${(recorded.backoffMs / 1000).toFixed(0)}s. Credentials in ${configPath} were not changed.`,
|
|
1851
|
+
);
|
|
1852
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, recorded.backoffMs));
|
|
1853
|
+
if (!compat.ok) {
|
|
1854
|
+
console.error(
|
|
1855
|
+
compatibilityError(PACKAGE_VERSION, gatewayVersion) ??
|
|
1856
|
+
`This remote-control ${PACKAGE_VERSION} cannot connect to gateway ${gatewayVersion}.`,
|
|
1857
|
+
);
|
|
1858
|
+
process.exit(1);
|
|
1859
|
+
}
|
|
1860
|
+
// Compatible (same major.minor) but newer patch unavailable — keep running on the old build.
|
|
1861
|
+
console.error('Continuing on the installed worker; will retry the update later.');
|
|
1862
|
+
return gatewayVersion;
|
|
1863
|
+
}
|
|
1864
|
+
} else if (gatewayUpdateError && diskAlreadyMatches) {
|
|
1865
|
+
console.error(
|
|
1866
|
+
`Worker update check reported ${gatewayUpdateError.message}; installed script already matches gateway ${gatewayRelease}. Not rewriting from npm.`,
|
|
1867
|
+
);
|
|
1868
|
+
} else if (gatewayUpdateError) {
|
|
1869
|
+
// Optional content refresh failed while still compatible — log and keep polling.
|
|
1870
|
+
console.error(`Worker update check failed: ${gatewayUpdateError.message}`);
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
if (!compat.ok) {
|
|
1874
|
+
// Update paths returned without exiting (should be rare); refuse with backoff.
|
|
1875
|
+
const recorded = await recordUpdateFailure(
|
|
1876
|
+
new Error(`Still incompatible with gateway ${gatewayVersion} after update attempts.`),
|
|
1877
|
+
);
|
|
1878
|
+
console.error(
|
|
1879
|
+
compatibilityError(PACKAGE_VERSION, gatewayVersion) ??
|
|
1880
|
+
`This remote-control ${PACKAGE_VERSION} cannot connect to gateway ${gatewayVersion}.`,
|
|
1881
|
+
);
|
|
1882
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, recorded.backoffMs));
|
|
1883
|
+
process.exit(1);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
return gatewayVersion;
|
|
869
1887
|
}
|
|
870
1888
|
|
|
871
1889
|
async function postResult(base, headers, result) {
|
|
@@ -939,6 +1957,29 @@ async function postResult(base, headers, result) {
|
|
|
939
1957
|
}
|
|
940
1958
|
|
|
941
1959
|
async function run() {
|
|
1960
|
+
await acquireWorkerLock();
|
|
1961
|
+
let stopMacIdle = () => {};
|
|
1962
|
+
const shutdown = () => {
|
|
1963
|
+
try {
|
|
1964
|
+
stopMacIdle();
|
|
1965
|
+
} catch {
|
|
1966
|
+
// ignore
|
|
1967
|
+
}
|
|
1968
|
+
try {
|
|
1969
|
+
const existing = readWorkerLockPid();
|
|
1970
|
+
if (existing === process.pid) rmSync(workerLockPath, { force: true });
|
|
1971
|
+
} catch {
|
|
1972
|
+
// Best-effort unlock.
|
|
1973
|
+
}
|
|
1974
|
+
};
|
|
1975
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
1976
|
+
process.once(signal, () => {
|
|
1977
|
+
shutdown();
|
|
1978
|
+
process.exit(0);
|
|
1979
|
+
});
|
|
1980
|
+
}
|
|
1981
|
+
process.once('exit', shutdown);
|
|
1982
|
+
|
|
942
1983
|
const config = await loadConfig();
|
|
943
1984
|
const capabilities = [];
|
|
944
1985
|
let probeMacUiState = () => 'unknown';
|
|
@@ -953,12 +1994,8 @@ async function run() {
|
|
|
953
1994
|
try {
|
|
954
1995
|
const mac = await import('./computer-macos.mjs');
|
|
955
1996
|
mac.startWorkerIdleAssertion(process.pid);
|
|
1997
|
+
stopMacIdle = () => mac.stopWorkerIdleAssertion();
|
|
956
1998
|
probeMacUiState = () => mac.probeMacUiState();
|
|
957
|
-
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
958
|
-
process.once(signal, () => {
|
|
959
|
-
mac.stopWorkerIdleAssertion();
|
|
960
|
-
});
|
|
961
|
-
}
|
|
962
1999
|
} catch {
|
|
963
2000
|
// Idle assertion / uiState probe optional; shell still works.
|
|
964
2001
|
}
|
|
@@ -971,33 +2008,26 @@ async function run() {
|
|
|
971
2008
|
...(capabilities.length ? { 'x-jian-remote-capabilities': capabilities.join(',') } : {}),
|
|
972
2009
|
});
|
|
973
2010
|
const base = `${config.gateway}/v1/remote/worker/${config.machineId}`;
|
|
974
|
-
//
|
|
975
|
-
|
|
2011
|
+
// Match the gateway worker (including major.minor skew) before polling so fleets do not
|
|
2012
|
+
// crash-loop on refuse without attempting a self-update first.
|
|
2013
|
+
const gatewayVersion = await reconcileWorkerWithGateway(config, base, pollHeaders());
|
|
976
2014
|
console.log(
|
|
977
2015
|
`Connected to Jian Remote at ${config.gateway} (worker ${PACKAGE_VERSION}${gatewayVersion ? `, gateway ${gatewayVersion}` : ''}${capabilities.length ? `, capabilities ${capabilities.join(',')}` : ''})`,
|
|
978
2016
|
);
|
|
979
2017
|
let failures = 0;
|
|
980
|
-
|
|
2018
|
+
// First reconcile already ran; space the next check like the historical five-minute cadence.
|
|
2019
|
+
let nextUpdateCheck = Date.now() + 5 * 60_000;
|
|
981
2020
|
const delay = async (attempt) => {
|
|
982
2021
|
const ceiling = Math.min(60_000, 1000 * 2 ** Math.min(attempt, 6));
|
|
983
2022
|
const wait = Math.random() * ceiling;
|
|
984
2023
|
console.error(`Retrying in ${(wait / 1000).toFixed(1)}s (attempt ${attempt})…`);
|
|
985
2024
|
await new Promise((resolveDelay) => setTimeout(resolveDelay, wait));
|
|
986
2025
|
};
|
|
987
|
-
const ensureCompatible = async () => {
|
|
988
|
-
const current = await fetchGatewayVersion(config.gateway);
|
|
989
|
-
const error = compatibilityError(PACKAGE_VERSION, current);
|
|
990
|
-
if (error) {
|
|
991
|
-
console.error(error);
|
|
992
|
-
process.exit(1);
|
|
993
|
-
}
|
|
994
|
-
};
|
|
995
2026
|
while (true) {
|
|
996
2027
|
try {
|
|
997
2028
|
const headers = pollHeaders();
|
|
998
2029
|
if (Date.now() >= nextUpdateCheck) {
|
|
999
|
-
await
|
|
1000
|
-
await updateIfNeeded(base, headers);
|
|
2030
|
+
await reconcileWorkerWithGateway(config, base, headers);
|
|
1001
2031
|
nextUpdateCheck = Date.now() + 5 * 60_000;
|
|
1002
2032
|
}
|
|
1003
2033
|
const response = await fetch(`${base}/tasks/next`, {
|
|
@@ -1041,25 +2071,34 @@ export function isCliEntry(argv1 = process.argv[1], metaUrl = import.meta.url) {
|
|
|
1041
2071
|
|
|
1042
2072
|
if (isCliEntry()) {
|
|
1043
2073
|
try {
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
else if (
|
|
1047
|
-
else if (
|
|
2074
|
+
const invocation = parseCliInvocation(args);
|
|
2075
|
+
if (invocation.command === 'version') await printVersionAndExit();
|
|
2076
|
+
else if (invocation.command === 'install') await install();
|
|
2077
|
+
else if (invocation.command === 'configure') await configure();
|
|
2078
|
+
else if (invocation.command === 'update') await update();
|
|
2079
|
+
else if (invocation.command === 'stop') await cliStop();
|
|
2080
|
+
else if (invocation.command === 'start') await cliStart();
|
|
2081
|
+
else if (invocation.command === 'restart') await cliRestart();
|
|
2082
|
+
else if (invocation.command === 'run') await run();
|
|
1048
2083
|
else {
|
|
1049
2084
|
const color = shouldColorize();
|
|
1050
2085
|
const title = paint('Jian Remote', ANSI.bold, color);
|
|
1051
2086
|
const pkg = paint('(@jian-ai/remote-control)', ANSI.dim, color);
|
|
1052
2087
|
console.log(`${title} worker ${pkg}
|
|
2088
|
+
version | --version | -V
|
|
1053
2089
|
install --gateway HTTPS_URL --machine UUID (--token VALUE | --token-prompt | --token-stdin)
|
|
1054
2090
|
configure [--gateway HTTPS_URL] [--machine UUID] (--token VALUE | --token-prompt | --token-stdin)
|
|
1055
2091
|
update [--version X.Y.Z|latest]
|
|
2092
|
+
stop | start | restart
|
|
1056
2093
|
run
|
|
1057
2094
|
|
|
1058
2095
|
Install: npm install -g @jian-ai/remote-control
|
|
1059
2096
|
Or: npx @jian-ai/remote-control@latest install --gateway … --machine … --token …
|
|
1060
2097
|
Configure: jian-remote configure --token … (or --gateway / --machine; restarts the service)
|
|
1061
2098
|
Update: npx @jian-ai/remote-control@latest update
|
|
1062
|
-
(upgrades the worker binary; reads ~/.config/jian-remote)
|
|
2099
|
+
(upgrades the worker binary; reads ~/.config/jian-remote)
|
|
2100
|
+
Service: jian-remote stop | start | restart
|
|
2101
|
+
Version: jian-remote version`);
|
|
1063
2102
|
}
|
|
1064
2103
|
} catch (error) {
|
|
1065
2104
|
const color = shouldColorize({ isTTY: process.stderr.isTTY });
|