@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.
@@ -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 { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
5
- import { chmod, copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
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.2';
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({ label, executable, script, logPath }) {
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 into Jian Remote.app and ad-hoc sign so Login Items / TCC
165
- * attach to Jian, not the shared system node (unsigned / ad-hoc — not Developer ID).
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
- const paths = macosAppBundlePaths();
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
- const source = process.execPath;
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(source, temp);
529
+ await copyFile(realSource, temp);
174
530
  await chmod(temp, 0o755);
175
531
  await rename(temp, paths.executable);
176
- // Best-effort ad-hoc sign with our bundle id; ignore failures on locked-down hosts.
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}). Run: npx @jian-ai/remote-control@${gateway.raw} update`,
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
- /** Copy a worker script into ~/.config so npx and global installs share one service path. */
482
- async function materializeWorkerScript(sourceBytes) {
483
- await mkdir(configDir, { recursive: true, mode: 0o700 });
484
- if (sourceBytes) {
485
- const temp = `${installedScript}.${process.pid}.tmp`;
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 or bounce the per-user service so it runs the installed script and reconnects.
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 startOrRestartService(script) {
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 = join(launchAgents, 'com.jian.remote.plist');
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(join(unitDir, 'jian-remote.service'), unit, { mode: 0o600 });
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: 'jian-remote.service' };
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
- 'Jian Remote',
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', 'Jian Remote'],
1207
+ ['/Run', '/TN', WINDOWS_TASK_NAME],
593
1208
  'Could not start the Jian Remote scheduled task',
594
1209
  );
595
- return { service: 'Task Scheduler', detail: 'Jian Remote (ONLOGON)' };
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. Otherwise pull the exact package from npm.
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
- printInfo(`Downloading @jian-ai/remote-control@${target}…`);
735
- scriptBytes = fetchNpmWorkerScript(target);
736
- installedVersion = readEmbeddedVersion(scriptBytes.toString('utf8'));
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
- async function executeShell(task) {
767
- return await new Promise((resolveResult) => {
768
- const command = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh';
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
- process.platform === 'win32'
1537
+ platform === 'win32'
771
1538
  ? ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', task.command]
772
1539
  : ['-lc', task.command];
773
- const child = spawn(command, commandArgs, {
774
- cwd: task.cwd || homedir(),
775
- env: process.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 (process.platform === 'win32' && child.pid) {
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.on('data', (chunk) => {
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.on('data', (chunk) => {
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(stderr, error.message, STDERR_CAP),
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) return false;
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
- if (!updated.length || updated.length > 1_000_000) throw new Error('Invalid worker update size');
855
- await mkdir(configDir, { recursive: true, mode: 0o700 });
856
- const temp = `${script}.${process.pid}.tmp`;
857
- await writeFile(temp, updated, { mode: 0o700 });
858
- await rename(temp, script);
859
- console.log('Worker updated; restarting to apply it.');
860
- if (process.platform === 'win32') {
861
- const child = spawn(process.execPath, [script, 'run'], {
862
- detached: true,
863
- stdio: 'ignore',
864
- windowsHide: true,
865
- });
866
- child.unref();
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
- process.exit(0);
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
- // Fail fast before the poll loop so an incompatible build does not look "online then stuck".
975
- const gatewayVersion = await assertCompatibleWithGateway(config.gateway);
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
- let nextUpdateCheck = 0;
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 ensureCompatible();
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
- if (args[0] === 'install') await install();
1045
- else if (args[0] === 'configure') await configure();
1046
- else if (args[0] === 'update') await update();
1047
- else if (args[0] === 'run') await run();
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 });