@jian-ai/remote-control 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from 'node:child_process';
3
3
  import { createHash } from 'node:crypto';
4
- import { mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
4
+ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
5
5
  import { chmod, copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
6
6
  import { homedir, tmpdir } from 'node:os';
7
- import { join, resolve } from 'node:path';
7
+ import { dirname, join, resolve } from 'node:path';
8
8
  import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
 
10
10
  const configDir = join(homedir(), '.config', 'jian-remote');
@@ -21,7 +21,7 @@ const option = (name) => {
21
21
  * Stamped to the release tag on npm publish and when the gateway serves this script.
22
22
  * Compatibility with the gateway requires the same major.minor (see versionsCompatible).
23
23
  */
24
- export const PACKAGE_VERSION = '0.7.1';
24
+ export const PACKAGE_VERSION = '0.8.0';
25
25
 
26
26
  /** Soft stdout/stderr caps while capturing a command (chars). Gateway schema matches these. */
27
27
  const STDOUT_CAP = 400_000;
@@ -34,6 +34,150 @@ const RESULT_BODY_BUDGET = 480_000;
34
34
  /** Permanent HTTP statuses — retrying forever leaves the task stuck and the machine offline. */
35
35
  const PERMANENT_RESULT_STATUS = new Set([400, 401, 403, 404, 413]);
36
36
  const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
37
+ const MACOS_BUNDLE_ID = 'com.jian.remote';
38
+ const MACOS_APP_NAME = 'Jian Remote';
39
+
40
+ const ANSI = {
41
+ reset: '\u001b[0m',
42
+ bold: '\u001b[1m',
43
+ dim: '\u001b[2m',
44
+ green: '\u001b[32m',
45
+ yellow: '\u001b[33m',
46
+ red: '\u001b[31m',
47
+ };
48
+
49
+ /** Color when stdout is a TTY, unless NO_COLOR; FORCE_COLOR=1 enables even when piped. */
50
+ export function shouldColorize(options = {}) {
51
+ // Use `in` so tests can pass forceColor: undefined without falling back to the process env.
52
+ const isTTY = 'isTTY' in options ? options.isTTY : process.stdout.isTTY;
53
+ const noColor = 'noColor' in options ? options.noColor : process.env.NO_COLOR;
54
+ const forceColor = 'forceColor' in options ? options.forceColor : process.env.FORCE_COLOR;
55
+ if (noColor !== undefined && noColor !== '') return false;
56
+ if (forceColor !== undefined && forceColor !== '' && forceColor !== '0') return true;
57
+ return Boolean(isTTY);
58
+ }
59
+
60
+ const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
61
+
62
+ export function stripAnsi(text) {
63
+ return text.replace(ANSI_PATTERN, '');
64
+ }
65
+
66
+ function paint(text, code, color) {
67
+ return color ? `${code}${text}${ANSI.reset}` : text;
68
+ }
69
+
70
+ /**
71
+ * Tasteful success block for install/configure/update — headline plus aligned facts.
72
+ * Keep facts short; avoid rainbow spam.
73
+ */
74
+ export function formatSuccessBlock({ headline, facts = [], color = shouldColorize() }) {
75
+ const mark = paint('✓', ANSI.green, color);
76
+ const title = paint(headline, ANSI.bold, color);
77
+ const lines = [`${mark} ${title}`];
78
+ const width = Math.min(12, Math.max(0, ...facts.map(([label]) => String(label).length)));
79
+ for (const [label, value] of facts) {
80
+ const key = paint(String(label).padEnd(width), ANSI.dim, color);
81
+ lines.push(` ${key} ${value}`);
82
+ }
83
+ return `${lines.join('\n')}\n`;
84
+ }
85
+
86
+ function printSuccess(headline, facts) {
87
+ process.stdout.write(formatSuccessBlock({ headline, facts, color: shouldColorize() }));
88
+ }
89
+
90
+ function printInfo(message) {
91
+ const color = shouldColorize();
92
+ const prefix = paint('·', ANSI.dim, color);
93
+ console.log(`${prefix} ${message}`);
94
+ }
95
+
96
+ function xmlEscape(value) {
97
+ return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
98
+ }
99
+
100
+ /** Paths for the thin macOS .app that owns Login Items / TCC identity (not system node). */
101
+ export function macosAppBundlePaths(home = homedir()) {
102
+ const appRoot = join(
103
+ home,
104
+ 'Library',
105
+ 'Application Support',
106
+ MACOS_APP_NAME,
107
+ `${MACOS_APP_NAME}.app`,
108
+ );
109
+ return {
110
+ appRoot,
111
+ contents: join(appRoot, 'Contents'),
112
+ macosDir: join(appRoot, 'Contents', 'MacOS'),
113
+ executable: join(appRoot, 'Contents', 'MacOS', MACOS_APP_NAME),
114
+ infoPlist: join(appRoot, 'Contents', 'Info.plist'),
115
+ bundleId: MACOS_BUNDLE_ID,
116
+ };
117
+ }
118
+
119
+ export function buildMacosAppInfoPlist() {
120
+ return `<?xml version="1.0" encoding="UTF-8"?>
121
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
122
+ <plist version="1.0"><dict>
123
+ <key>CFBundleDevelopmentRegion</key><string>en</string>
124
+ <key>CFBundleDisplayName</key><string>${MACOS_APP_NAME}</string>
125
+ <key>CFBundleExecutable</key><string>${MACOS_APP_NAME}</string>
126
+ <key>CFBundleIdentifier</key><string>${MACOS_BUNDLE_ID}</string>
127
+ <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
128
+ <key>CFBundleName</key><string>${MACOS_APP_NAME}</string>
129
+ <key>CFBundlePackageType</key><string>APPL</string>
130
+ <key>CFBundleShortVersionString</key><string>1.0</string>
131
+ <key>CFBundleVersion</key><string>1</string>
132
+ <key>LSMinimumSystemVersion</key><string>12.0</string>
133
+ <key>NSHighResolutionCapable</key><true/>
134
+ </dict></plist>
135
+ `;
136
+ }
137
+
138
+ export function buildLaunchAgentPlist({ label, executable, script, logPath }) {
139
+ return `<?xml version="1.0" encoding="UTF-8"?>
140
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
141
+ <plist version="1.0"><dict>
142
+ <key>Label</key><string>${xmlEscape(label)}</string>
143
+ <key>AssociatedBundleIdentifiers</key>
144
+ <array><string>${MACOS_BUNDLE_ID}</string></array>
145
+ <key>ProgramArguments</key>
146
+ <array>
147
+ <string>${xmlEscape(executable)}</string>
148
+ <string>${xmlEscape(script)}</string>
149
+ <string>run</string>
150
+ </array>
151
+ <key>RunAtLoad</key><true/>
152
+ <key>KeepAlive</key><true/>
153
+ <key>StandardOutPath</key><string>${xmlEscape(logPath)}</string>
154
+ <key>StandardErrorPath</key><string>${xmlEscape(logPath)}</string>
155
+ </dict></plist>
156
+ `;
157
+ }
158
+
159
+ /**
160
+ * Copy the current Node binary into Jian Remote.app and ad-hoc sign so Login Items / TCC
161
+ * attach to Jian, not the shared system node (unsigned / ad-hoc — not Developer ID).
162
+ */
163
+ async function materializeMacosAppRuntime() {
164
+ const paths = macosAppBundlePaths();
165
+ await mkdir(paths.macosDir, { recursive: true, mode: 0o755 });
166
+ await writeFile(paths.infoPlist, buildMacosAppInfoPlist(), { mode: 0o644 });
167
+ const source = process.execPath;
168
+ const temp = `${paths.executable}.${process.pid}.tmp`;
169
+ await copyFile(source, temp);
170
+ await chmod(temp, 0o755);
171
+ await rename(temp, paths.executable);
172
+ // Best-effort ad-hoc sign with our bundle id; ignore failures on locked-down hosts.
173
+ spawnSync('codesign', ['--force', '-s', '-', '--identifier', MACOS_BUNDLE_ID, paths.executable], {
174
+ stdio: 'ignore',
175
+ });
176
+ spawnSync('codesign', ['--force', '-s', '-', '--identifier', MACOS_BUNDLE_ID, paths.appRoot], {
177
+ stdio: 'ignore',
178
+ });
179
+ return paths;
180
+ }
37
181
 
38
182
  export function parseRelease(version) {
39
183
  const raw = typeof version === 'string' ? version.replace(/^v/, '').trim() : '';
@@ -112,6 +256,7 @@ export function cappedAppend(current, chunk, cap = STDOUT_CAP) {
112
256
  export function fitResult(result, budget = RESULT_BODY_BUDGET) {
113
257
  let stdout = result.stdout ?? '';
114
258
  let stderr = result.stderr ?? '';
259
+ const computer = result.computer;
115
260
  const pack = () =>
116
261
  JSON.stringify({
117
262
  taskId: result.taskId,
@@ -119,10 +264,14 @@ export function fitResult(result, budget = RESULT_BODY_BUDGET) {
119
264
  stdout,
120
265
  stderr,
121
266
  timedOut: Boolean(result.timedOut),
267
+ ...(computer ? { computer } : {}),
122
268
  });
123
269
  let body = pack();
124
270
  if (Buffer.byteLength(body) <= budget) {
125
- return { result: { ...result, stdout, stderr, timedOut: Boolean(result.timedOut) }, body };
271
+ return {
272
+ result: { ...result, stdout, stderr, timedOut: Boolean(result.timedOut), computer },
273
+ body,
274
+ };
126
275
  }
127
276
 
128
277
  const note = '\n… truncated to fit the gateway size limit';
@@ -337,29 +486,57 @@ async function materializeWorkerScript(sourceBytes) {
337
486
  if (source !== installedScript) await copyFile(source, installedScript);
338
487
  }
339
488
  if (process.platform !== 'win32') await chmod(installedScript, 0o700);
489
+ // Companion modules + macOS helper source (computer use). Best-effort beside the worker.
490
+ try {
491
+ const here = dirname(fileURLToPath(import.meta.url));
492
+ const computerSrc = join(here, 'computer-macos.mjs');
493
+ const computerDest = join(configDir, 'computer-macos.mjs');
494
+ if (existsSync(computerSrc) && computerSrc !== computerDest) {
495
+ await copyFile(computerSrc, computerDest);
496
+ if (process.platform !== 'win32') await chmod(computerDest, 0o700);
497
+ }
498
+ if (process.platform === 'darwin') {
499
+ const { materializeComputerHelperSources } = await import('./computer-macos.mjs');
500
+ await materializeComputerHelperSources(configDir);
501
+ }
502
+ } catch {
503
+ // Install still succeeds; computer-use probe will explain if the helper is missing.
504
+ }
340
505
  return installedScript;
341
506
  }
342
507
 
343
- /** Register or bounce the per-user service so it runs the installed script and reconnects. */
508
+ /**
509
+ * Register or bounce the per-user service so it runs the installed script and reconnects.
510
+ * On macOS, ProgramArguments[0] is the Jian Remote.app Node copy — not system node — so
511
+ * Login Items / TCC show "Jian Remote".
512
+ */
344
513
  async function startOrRestartService(script) {
345
514
  if (process.platform === 'darwin') {
346
515
  const launchAgents = join(homedir(), 'Library', 'LaunchAgents');
347
516
  const plistPath = join(launchAgents, 'com.jian.remote.plist');
348
- const xml = (value) =>
349
- value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
350
517
  const label = 'com.jian.remote';
518
+ const app = await materializeMacosAppRuntime();
351
519
  await mkdir(launchAgents, { recursive: true, mode: 0o700 });
352
520
  await writeFile(
353
521
  plistPath,
354
- `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${label}</string><key>ProgramArguments</key><array><string>${xml(process.execPath)}</string><string>${xml(script)}</string><string>run</string></array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>StandardOutPath</key><string>${xml(join(configDir, 'worker.log'))}</string><key>StandardErrorPath</key><string>${xml(join(configDir, 'worker.log'))}</string></dict></plist>\n`,
522
+ buildLaunchAgentPlist({
523
+ label,
524
+ executable: app.executable,
525
+ script,
526
+ logPath: join(configDir, 'worker.log'),
527
+ }),
355
528
  { mode: 0o600 },
356
529
  );
357
530
  const domain = `gui/${process.getuid()}`;
358
531
  spawnSync('launchctl', ['bootout', domain, plistPath], { stdio: 'ignore' });
359
532
  const loaded = spawnSync('launchctl', ['bootstrap', domain, plistPath], { stdio: 'inherit' });
360
533
  if (loaded.status !== 0) throw new Error('Could not start Jian Remote with launchd');
361
- console.log('Installed and started the per-user launchd service.');
362
- } else if (process.platform === 'linux') {
534
+ return {
535
+ service: 'LaunchAgent',
536
+ detail: `Login Items identity: ${MACOS_APP_NAME}.app (${app.appRoot})`,
537
+ };
538
+ }
539
+ if (process.platform === 'linux') {
363
540
  const unitDir = join(homedir(), '.config', 'systemd', 'user');
364
541
  await mkdir(unitDir, { recursive: true, mode: 0o700 });
365
542
  const quote = (value) => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
@@ -374,8 +551,9 @@ async function startOrRestartService(script) {
374
551
  if (result.status !== 0)
375
552
  throw new Error('Could not install/start Jian Remote with systemd --user');
376
553
  }
377
- console.log('Installed and started the per-user systemd service.');
378
- } else if (process.platform === 'win32') {
554
+ return { service: 'systemd --user', detail: 'jian-remote.service' };
555
+ }
556
+ if (process.platform === 'win32') {
379
557
  const action = `"${process.execPath}" "${script}" run`;
380
558
  spawnSync('schtasks.exe', ['/End', '/TN', 'Jian Remote'], {
381
559
  stdio: 'ignore',
@@ -403,12 +581,10 @@ async function startOrRestartService(script) {
403
581
  ['/Run', '/TN', 'Jian Remote'],
404
582
  'Could not start the Jian Remote scheduled task',
405
583
  );
406
- console.log('Installed and started the per-user Windows logon task.');
407
- } else {
408
- console.log(
409
- `Service auto-install is unsupported on ${process.platform}. Run: node ${script} run`,
410
- );
584
+ return { service: 'Task Scheduler', detail: 'Jian Remote (ONLOGON)' };
411
585
  }
586
+ printInfo(`Service auto-install is unsupported on ${process.platform}. Run: node ${script} run`);
587
+ return { service: 'none', detail: process.platform };
412
588
  }
413
589
 
414
590
  async function install() {
@@ -431,21 +607,21 @@ async function install() {
431
607
  };
432
608
  await writeConfigFile(config);
433
609
  const script = await materializeWorkerScript();
434
- await startOrRestartService(script);
435
- console.log(`Credentials are stored with owner-only permissions in ${configPath}.`);
610
+ const started = await startOrRestartService(script);
611
+ const facts = [
612
+ ['Service', `${started.service} started`],
613
+ ['Config', configPath],
614
+ ];
436
615
  if (gatewayVersion) {
437
- console.log(
438
- `Compatible with gateway ${gatewayVersion} (major.minor ${parseRelease(gatewayVersion)?.major}.${parseRelease(gatewayVersion)?.minor}).`,
439
- );
616
+ const release = parseRelease(gatewayVersion);
617
+ facts.push(['Gateway', `${gatewayVersion} (major.minor ${release?.major}.${release?.minor})`]);
440
618
  }
441
- console.log(
442
- 'Later upgrades: npx @jian-ai/remote-control@latest update (reads this config; no token again).',
443
- );
444
- console.log(
445
- 'To change gateway URL or token without reinstalling: jian-remote configure --gateway … --token …',
446
- );
447
- console.log(
448
- 'If the worker later looks stuck offline, restart the service (or run update) and check ~/.config/jian-remote/worker.log.',
619
+ if (started.detail) facts.push(['Identity', started.detail]);
620
+ printSuccess('Installed Jian Remote', facts);
621
+ printInfo('Upgrade later: npx @jian-ai/remote-control@latest update (no token again)');
622
+ printInfo('Change URL or token: jian-remote configure --gateway … --token …');
623
+ printInfo(
624
+ 'If Offline sticks: update or restart the service; check ~/.config/jian-remote/worker.log',
449
625
  );
450
626
  }
451
627
 
@@ -498,19 +674,23 @@ async function configure() {
498
674
  } catch {
499
675
  script = await materializeWorkerScript();
500
676
  }
501
- await startOrRestartService(script);
677
+ const started = await startOrRestartService(script);
502
678
 
503
679
  const changed = [
504
680
  gatewayFlag ? 'gateway' : null,
505
681
  machineFlag ? 'machine' : null,
506
682
  tokenFlag ? 'token' : null,
507
683
  ].filter(Boolean);
508
- console.log(`Updated ${changed.join(', ')} in ${configPath}. Worker service restarted.`);
684
+ const facts = [
685
+ ['Changed', changed.join(', ')],
686
+ ['Config', configPath],
687
+ ['Service', `${started.service} restarted`],
688
+ ];
509
689
  if (gatewayVersion) {
510
- console.log(
511
- `Compatible with gateway ${gatewayVersion} (major.minor ${parseRelease(gatewayVersion)?.major}.${parseRelease(gatewayVersion)?.minor}).`,
512
- );
690
+ const release = parseRelease(gatewayVersion);
691
+ facts.push(['Gateway', `${gatewayVersion} (major.minor ${release?.major}.${release?.minor})`]);
513
692
  }
693
+ printSuccess('Configured Jian Remote', facts);
514
694
  }
515
695
 
516
696
  /**
@@ -540,7 +720,7 @@ async function update() {
540
720
  scriptBytes = await readFile(fileURLToPath(import.meta.url));
541
721
  installedVersion = PACKAGE_VERSION;
542
722
  } else {
543
- console.log(`Downloading @jian-ai/remote-control@${target}…`);
723
+ printInfo(`Downloading @jian-ai/remote-control@${target}…`);
544
724
  scriptBytes = fetchNpmWorkerScript(target);
545
725
  installedVersion = readEmbeddedVersion(scriptBytes.toString('utf8'));
546
726
  }
@@ -557,18 +737,22 @@ async function update() {
557
737
  { stdio: 'inherit', windowsHide: true },
558
738
  );
559
739
  if (npmInstall.status !== 0) {
560
- console.log(
740
+ printInfo(
561
741
  'Global npm install skipped or failed; the service still uses ~/.config/jian-remote/jian-remote.mjs.',
562
742
  );
563
743
  }
564
744
 
565
- await startOrRestartService(script);
566
- console.log(
567
- `Updated Jian Remote to ${installedVersion}${gatewayVersion ? ` (gateway ${gatewayVersion})` : ''}. Worker restarted.`,
568
- );
745
+ const started = await startOrRestartService(script);
746
+ const facts = [
747
+ ['Version', installedVersion],
748
+ ['Service', `${started.service} restarted`],
749
+ ];
750
+ if (gatewayVersion) facts.push(['Gateway', gatewayVersion]);
751
+ if (started.detail) facts.push(['Identity', started.detail]);
752
+ printSuccess('Updated Jian Remote', facts);
569
753
  }
570
754
 
571
- async function execute(task) {
755
+ async function executeShell(task) {
572
756
  return await new Promise((resolveResult) => {
573
757
  const command = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh';
574
758
  const commandArgs =
@@ -582,6 +766,7 @@ async function execute(task) {
582
766
  });
583
767
  let stdout = '';
584
768
  let stderr = '';
769
+ let stdoutSeen = 0;
585
770
  let timedOut = false;
586
771
  const timeout = setTimeout(() => {
587
772
  timedOut = true;
@@ -598,6 +783,7 @@ async function execute(task) {
598
783
  }, 2000).unref();
599
784
  }, task.timeoutMs);
600
785
  child.stdout.on('data', (chunk) => {
786
+ stdoutSeen += chunk.toString('utf8').length;
601
787
  stdout = cappedAppend(stdout, chunk, STDOUT_CAP);
602
788
  });
603
789
  child.stderr.on('data', (chunk) => {
@@ -615,11 +801,37 @@ async function execute(task) {
615
801
  });
616
802
  child.on('close', (code) => {
617
803
  clearTimeout(timeout);
804
+ // Mark silent stdout cuts so screenshot tools can reject grey/truncated JPEGs.
805
+ if (stdoutSeen > STDOUT_CAP) {
806
+ stderr = cappedAppend(
807
+ stderr,
808
+ '\n[jian-remote] stdout truncated at the worker channel cap\n',
809
+ STDERR_CAP,
810
+ );
811
+ }
618
812
  resolveResult({ taskId: task.id, exitCode: code, stdout, stderr, timedOut });
619
813
  });
620
814
  });
621
815
  }
622
816
 
817
+ async function execute(task) {
818
+ if (task.kind === 'computer') {
819
+ if (process.platform !== 'darwin') {
820
+ return {
821
+ taskId: task.id,
822
+ exitCode: 1,
823
+ stdout: '',
824
+ stderr: 'Computer use is supported on macOS Jian Remote only.',
825
+ timedOut: false,
826
+ computer: { ok: false, error: 'Computer use is supported on macOS Jian Remote only.' },
827
+ };
828
+ }
829
+ const { executeComputerTask } = await import('./computer-macos.mjs');
830
+ return executeComputerTask(task);
831
+ }
832
+ return executeShell(task);
833
+ }
834
+
623
835
  async function updateIfNeeded(base, headers) {
624
836
  // Prefer the installed copy under ~/.config so updates land where the service points.
625
837
  const script = installedScript;
@@ -727,16 +939,26 @@ async function postResult(base, headers, result) {
727
939
 
728
940
  async function run() {
729
941
  const config = await loadConfig();
942
+ const capabilities = [];
943
+ if (process.platform === 'darwin') {
944
+ try {
945
+ const { probeComputerCapability, COMPUTER_CAPABILITY } = await import('./computer-macos.mjs');
946
+ if (await probeComputerCapability()) capabilities.push(COMPUTER_CAPABILITY);
947
+ } catch {
948
+ // Computer use unavailable until helper builds; shell tasks still work.
949
+ }
950
+ }
730
951
  const headers = {
731
952
  authorization: `Bearer ${config.token}`,
732
953
  'x-jian-platform': `${process.platform}-${process.arch}`,
733
954
  'x-jian-remote-version': PACKAGE_VERSION,
955
+ ...(capabilities.length ? { 'x-jian-remote-capabilities': capabilities.join(',') } : {}),
734
956
  };
735
957
  const base = `${config.gateway}/v1/remote/worker/${config.machineId}`;
736
958
  // Fail fast before the poll loop so an incompatible build does not look "online then stuck".
737
959
  const gatewayVersion = await assertCompatibleWithGateway(config.gateway);
738
960
  console.log(
739
- `Connected to Jian Remote at ${config.gateway} (worker ${PACKAGE_VERSION}${gatewayVersion ? `, gateway ${gatewayVersion}` : ''})`,
961
+ `Connected to Jian Remote at ${config.gateway} (worker ${PACKAGE_VERSION}${gatewayVersion ? `, gateway ${gatewayVersion}` : ''}${capabilities.length ? `, capabilities ${capabilities.join(',')}` : ''})`,
740
962
  );
741
963
  let failures = 0;
742
964
  let nextUpdateCheck = 0;
@@ -806,12 +1028,26 @@ if (isCliEntry()) {
806
1028
  else if (args[0] === 'configure') await configure();
807
1029
  else if (args[0] === 'update') await update();
808
1030
  else if (args[0] === 'run') await run();
809
- else
810
- console.log(
811
- 'Jian Remote worker (@jian-ai/remote-control)\n install --gateway HTTPS_URL --machine UUID (--token VALUE | --token-prompt | --token-stdin)\n configure [--gateway HTTPS_URL] [--machine UUID] (--token VALUE | --token-prompt | --token-stdin)\n update [--version X.Y.Z|latest]\n run\n\nInstall: npm install -g @jian-ai/remote-control\nOr: npx @jian-ai/remote-control@latest install --gateway … --machine … --token …\nConfigure: jian-remote configure --token … (or --gateway / --machine; restarts the service)\nUpdate: npx @jian-ai/remote-control@latest update\n (upgrades the worker binary; reads ~/.config/jian-remote)',
812
- );
1031
+ else {
1032
+ const color = shouldColorize();
1033
+ const title = paint('Jian Remote', ANSI.bold, color);
1034
+ const pkg = paint('(@jian-ai/remote-control)', ANSI.dim, color);
1035
+ console.log(`${title} worker ${pkg}
1036
+ install --gateway HTTPS_URL --machine UUID (--token VALUE | --token-prompt | --token-stdin)
1037
+ configure [--gateway HTTPS_URL] [--machine UUID] (--token VALUE | --token-prompt | --token-stdin)
1038
+ update [--version X.Y.Z|latest]
1039
+ run
1040
+
1041
+ Install: npm install -g @jian-ai/remote-control
1042
+ Or: npx @jian-ai/remote-control@latest install --gateway … --machine … --token …
1043
+ Configure: jian-remote configure --token … (or --gateway / --machine; restarts the service)
1044
+ Update: npx @jian-ai/remote-control@latest update
1045
+ (upgrades the worker binary; reads ~/.config/jian-remote)`);
1046
+ }
813
1047
  } catch (error) {
814
- console.error(error instanceof Error ? error.message : error);
1048
+ const color = shouldColorize({ isTTY: process.stderr.isTTY });
1049
+ const prefix = paint('Error', ANSI.red, color);
1050
+ console.error(`${prefix}: ${error instanceof Error ? error.message : error}`);
815
1051
  process.exitCode = 1;
816
1052
  }
817
1053
  }