@jian-ai/remote-control 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,6 +29,16 @@ npx @jian-ai/remote-control@latest update
29
29
  Reads `~/.config/jian-remote` (no token, machine id, or gateway flags), installs a compatible
30
30
  build, and restarts the worker service.
31
31
 
32
+ ## Configure
33
+
34
+ Change the stored gateway URL and/or worker token without a full reinstall (restarts the
35
+ existing OS service):
36
+
37
+ ```sh
38
+ jian-remote configure --token WORKER_TOKEN
39
+ jian-remote configure --gateway https://gateway.example --token WORKER_TOKEN
40
+ ```
41
+
32
42
  ## Compatibility
33
43
 
34
44
  Worker and gateway must share the same major.minor. See [docs/remote.md](../../docs/remote.md).
@@ -1,11 +1,11 @@
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, rmSync } from 'node:fs';
4
+ import { 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
7
  import { join, resolve } from 'node:path';
8
- import { fileURLToPath } from 'node:url';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
9
 
10
10
  const configDir = join(homedir(), '.config', 'jian-remote');
11
11
  const configPath = join(configDir, 'config.json');
@@ -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.0';
24
+ export const PACKAGE_VERSION = '0.7.2';
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() : '';
@@ -210,6 +354,61 @@ async function loadConfig() {
210
354
  }
211
355
  }
212
356
 
357
+ const MACHINE_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
358
+
359
+ function parseGatewayBase(gateway) {
360
+ let gatewayUrl;
361
+ try {
362
+ gatewayUrl = new URL(gateway);
363
+ } catch {
364
+ return undefined;
365
+ }
366
+ if (
367
+ gatewayUrl.protocol !== 'https:' ||
368
+ gatewayUrl.username ||
369
+ gatewayUrl.password ||
370
+ gatewayUrl.search ||
371
+ gatewayUrl.hash
372
+ ) {
373
+ return undefined;
374
+ }
375
+ return gatewayUrl.origin + gatewayUrl.pathname.replace(/\/$/, '');
376
+ }
377
+
378
+ async function readTokenOption() {
379
+ const inlineToken = option('--token');
380
+ if (inlineToken) return inlineToken;
381
+ if (args.includes('--token-stdin')) return await readStdin();
382
+ if (args.includes('--token-prompt')) return await readSecret('Worker token (input hidden): ');
383
+ return undefined;
384
+ }
385
+
386
+ /** Persist credentials with owner-only permissions under ~/.config/jian-remote. */
387
+ async function writeConfigFile(config) {
388
+ await mkdir(configDir, { recursive: true, mode: 0o700 });
389
+ if (process.platform === 'win32') {
390
+ const account = process.env.USERNAME;
391
+ if (!account) throw new Error('Could not identify the current Windows user');
392
+ runChecked(
393
+ 'icacls.exe',
394
+ [configDir, '/inheritance:r', '/grant:r', `${account}:(OI)(CI)F`],
395
+ 'Could not restrict permissions on the Jian Remote config directory',
396
+ );
397
+ } else {
398
+ await chmod(configDir, 0o700);
399
+ }
400
+ await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
401
+ if (process.platform === 'win32') {
402
+ runChecked(
403
+ 'icacls.exe',
404
+ [configPath, '/inheritance:r', '/grant:r', `${process.env.USERNAME}:F`],
405
+ 'Could not restrict permissions on the Jian Remote config file',
406
+ );
407
+ } else {
408
+ await chmod(configPath, 0o600);
409
+ }
410
+ }
411
+
213
412
  /** Public /health carries the gateway release so install/update can gate without a worker token. */
214
413
  export async function fetchGatewayVersion(gatewayBase, fetcher = fetch) {
215
414
  const response = await fetcher(`${gatewayBase.replace(/\/$/, '')}/health`, {
@@ -285,26 +484,38 @@ async function materializeWorkerScript(sourceBytes) {
285
484
  return installedScript;
286
485
  }
287
486
 
288
- /** Register or bounce the per-user service so it runs the installed script and reconnects. */
487
+ /**
488
+ * Register or bounce the per-user service so it runs the installed script and reconnects.
489
+ * On macOS, ProgramArguments[0] is the Jian Remote.app Node copy — not system node — so
490
+ * Login Items / TCC show "Jian Remote".
491
+ */
289
492
  async function startOrRestartService(script) {
290
493
  if (process.platform === 'darwin') {
291
494
  const launchAgents = join(homedir(), 'Library', 'LaunchAgents');
292
495
  const plistPath = join(launchAgents, 'com.jian.remote.plist');
293
- const xml = (value) =>
294
- value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
295
496
  const label = 'com.jian.remote';
497
+ const app = await materializeMacosAppRuntime();
296
498
  await mkdir(launchAgents, { recursive: true, mode: 0o700 });
297
499
  await writeFile(
298
500
  plistPath,
299
- `<?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`,
501
+ buildLaunchAgentPlist({
502
+ label,
503
+ executable: app.executable,
504
+ script,
505
+ logPath: join(configDir, 'worker.log'),
506
+ }),
300
507
  { mode: 0o600 },
301
508
  );
302
509
  const domain = `gui/${process.getuid()}`;
303
510
  spawnSync('launchctl', ['bootout', domain, plistPath], { stdio: 'ignore' });
304
511
  const loaded = spawnSync('launchctl', ['bootstrap', domain, plistPath], { stdio: 'inherit' });
305
512
  if (loaded.status !== 0) throw new Error('Could not start Jian Remote with launchd');
306
- console.log('Installed and started the per-user launchd service.');
307
- } else if (process.platform === 'linux') {
513
+ return {
514
+ service: 'LaunchAgent',
515
+ detail: `Login Items identity: ${MACOS_APP_NAME}.app (${app.appRoot})`,
516
+ };
517
+ }
518
+ if (process.platform === 'linux') {
308
519
  const unitDir = join(homedir(), '.config', 'systemd', 'user');
309
520
  await mkdir(unitDir, { recursive: true, mode: 0o700 });
310
521
  const quote = (value) => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
@@ -319,8 +530,9 @@ async function startOrRestartService(script) {
319
530
  if (result.status !== 0)
320
531
  throw new Error('Could not install/start Jian Remote with systemd --user');
321
532
  }
322
- console.log('Installed and started the per-user systemd service.');
323
- } else if (process.platform === 'win32') {
533
+ return { service: 'systemd --user', detail: 'jian-remote.service' };
534
+ }
535
+ if (process.platform === 'win32') {
324
536
  const action = `"${process.execPath}" "${script}" run`;
325
537
  spawnSync('schtasks.exe', ['/End', '/TN', 'Jian Remote'], {
326
538
  stdio: 'ignore',
@@ -348,12 +560,10 @@ async function startOrRestartService(script) {
348
560
  ['/Run', '/TN', 'Jian Remote'],
349
561
  'Could not start the Jian Remote scheduled task',
350
562
  );
351
- console.log('Installed and started the per-user Windows logon task.');
352
- } else {
353
- console.log(
354
- `Service auto-install is unsupported on ${process.platform}. Run: node ${script} run`,
355
- );
563
+ return { service: 'Task Scheduler', detail: 'Jian Remote (ONLOGON)' };
356
564
  }
565
+ printInfo(`Service auto-install is unsupported on ${process.platform}. Run: node ${script} run`);
566
+ return { service: 'none', detail: process.platform };
357
567
  }
358
568
 
359
569
  async function install() {
@@ -361,76 +571,105 @@ async function install() {
361
571
  const machineId = option('--machine');
362
572
  // Prefer an explicit --token for one-liner installs from the panel; stdin/prompt keep
363
573
  // the token out of shell history when someone installs by hand.
364
- const inlineToken = option('--token');
365
- const token = inlineToken
366
- ? inlineToken
367
- : args.includes('--token-stdin')
368
- ? await readStdin()
369
- : args.includes('--token-prompt')
370
- ? await readSecret('Worker token (input hidden): ')
371
- : undefined;
372
- let gatewayUrl;
373
- try {
374
- gatewayUrl = new URL(gateway);
375
- } catch {}
376
- if (
377
- gatewayUrl?.protocol !== 'https:' ||
378
- gatewayUrl.username ||
379
- gatewayUrl.password ||
380
- gatewayUrl.search ||
381
- gatewayUrl.hash ||
382
- !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
383
- machineId ?? '',
384
- ) ||
385
- !token?.trim()
386
- ) {
574
+ const token = await readTokenOption();
575
+ const gatewayBase = parseGatewayBase(gateway);
576
+ if (!gatewayBase || !MACHINE_ID.test(machineId ?? '') || !token?.trim()) {
387
577
  throw new Error(
388
578
  'Usage: jian-remote install --gateway https://... --machine UUID (--token VALUE | --token-prompt | --token-stdin)',
389
579
  );
390
580
  }
391
- const gatewayBase = gatewayUrl.origin + gatewayUrl.pathname.replace(/\/$/, '');
392
581
  const gatewayVersion = await assertCompatibleWithGateway(gatewayBase);
393
582
  const config = {
394
583
  gateway: gatewayBase,
395
584
  machineId,
396
585
  token: token.trim(),
397
586
  };
398
- await mkdir(configDir, { recursive: true, mode: 0o700 });
399
- if (process.platform === 'win32') {
400
- const account = process.env.USERNAME;
401
- if (!account) throw new Error('Could not identify the current Windows user');
402
- runChecked(
403
- 'icacls.exe',
404
- [configDir, '/inheritance:r', '/grant:r', `${account}:(OI)(CI)F`],
405
- 'Could not restrict permissions on the Jian Remote config directory',
406
- );
407
- } else {
408
- await chmod(configDir, 0o700);
587
+ await writeConfigFile(config);
588
+ const script = await materializeWorkerScript();
589
+ const started = await startOrRestartService(script);
590
+ const facts = [
591
+ ['Service', `${started.service} started`],
592
+ ['Config', configPath],
593
+ ];
594
+ if (gatewayVersion) {
595
+ const release = parseRelease(gatewayVersion);
596
+ facts.push(['Gateway', `${gatewayVersion} (major.minor ${release?.major}.${release?.minor})`]);
409
597
  }
410
- await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
411
- if (process.platform === 'win32') {
412
- runChecked(
413
- 'icacls.exe',
414
- [configPath, '/inheritance:r', '/grant:r', `${process.env.USERNAME}:F`],
415
- 'Could not restrict permissions on the Jian Remote config file',
598
+ if (started.detail) facts.push(['Identity', started.detail]);
599
+ printSuccess('Installed Jian Remote', facts);
600
+ printInfo('Upgrade later: npx @jian-ai/remote-control@latest update (no token again)');
601
+ printInfo('Change URL or token: jian-remote configure --gateway … --token …');
602
+ printInfo(
603
+ 'If Offline sticks: update or restart the service; check ~/.config/jian-remote/worker.log',
604
+ );
605
+ }
606
+
607
+ /**
608
+ * Rewrite stored credentials (gateway URL, worker token, and/or machine id) and restart the
609
+ * existing OS service — does not wipe the LaunchAgent / systemd unit / scheduled task.
610
+ */
611
+ async function configure() {
612
+ const gatewayFlag = option('--gateway');
613
+ const machineFlag = option('--machine');
614
+ const tokenFlag =
615
+ option('--token') !== undefined ||
616
+ args.includes('--token-stdin') ||
617
+ args.includes('--token-prompt');
618
+ if (!gatewayFlag && !machineFlag && !tokenFlag) {
619
+ throw new Error(
620
+ 'Usage: jian-remote configure [--gateway https://...] [--machine UUID] (--token VALUE | --token-prompt | --token-stdin)\nChange at least one of gateway, machine, or token. Reads ~/.config/jian-remote; restarts the service.',
416
621
  );
417
- } else {
418
- await chmod(configPath, 0o600);
419
622
  }
420
- const script = await materializeWorkerScript();
421
- await startOrRestartService(script);
422
- console.log(`Credentials are stored with owner-only permissions in ${configPath}.`);
623
+ const existing = await loadConfig();
624
+
625
+ const gatewayBase = gatewayFlag ? parseGatewayBase(gatewayFlag) : undefined;
626
+ if (gatewayFlag && !gatewayBase) {
627
+ throw new Error('Gateway must be an https:// URL with no credentials, query, or hash.');
628
+ }
629
+ const machineId = machineFlag ?? existing.machineId;
630
+ if (!MACHINE_ID.test(machineId ?? '')) {
631
+ throw new Error('Machine id must be a UUID.');
632
+ }
633
+ const token = tokenFlag ? await readTokenOption() : existing.token;
634
+ if (!token?.trim()) {
635
+ throw new Error('Worker token must not be empty.');
636
+ }
637
+
638
+ const config = {
639
+ gateway: gatewayBase ?? existing.gateway,
640
+ machineId,
641
+ token: token.trim(),
642
+ };
643
+ if (typeof config.gateway !== 'string' || !config.gateway.startsWith('https://')) {
644
+ throw new Error(`Invalid gateway in ${configPath}; re-run install from the panel.`);
645
+ }
646
+
647
+ const gatewayVersion = await assertCompatibleWithGateway(config.gateway);
648
+ await writeConfigFile(config);
649
+ // Prefer the already-installed script so configure does not depend on the npx cache.
650
+ let script = installedScript;
651
+ try {
652
+ await readFile(script);
653
+ } catch {
654
+ script = await materializeWorkerScript();
655
+ }
656
+ const started = await startOrRestartService(script);
657
+
658
+ const changed = [
659
+ gatewayFlag ? 'gateway' : null,
660
+ machineFlag ? 'machine' : null,
661
+ tokenFlag ? 'token' : null,
662
+ ].filter(Boolean);
663
+ const facts = [
664
+ ['Changed', changed.join(', ')],
665
+ ['Config', configPath],
666
+ ['Service', `${started.service} restarted`],
667
+ ];
423
668
  if (gatewayVersion) {
424
- console.log(
425
- `Compatible with gateway ${gatewayVersion} (major.minor ${parseRelease(gatewayVersion)?.major}.${parseRelease(gatewayVersion)?.minor}).`,
426
- );
669
+ const release = parseRelease(gatewayVersion);
670
+ facts.push(['Gateway', `${gatewayVersion} (major.minor ${release?.major}.${release?.minor})`]);
427
671
  }
428
- console.log(
429
- 'Later upgrades: npx @jian-ai/remote-control@latest update (reads this config; no token again).',
430
- );
431
- console.log(
432
- 'If the worker later looks stuck offline, restart the service (or run update) and check ~/.config/jian-remote/worker.log.',
433
- );
672
+ printSuccess('Configured Jian Remote', facts);
434
673
  }
435
674
 
436
675
  /**
@@ -460,7 +699,7 @@ async function update() {
460
699
  scriptBytes = await readFile(fileURLToPath(import.meta.url));
461
700
  installedVersion = PACKAGE_VERSION;
462
701
  } else {
463
- console.log(`Downloading @jian-ai/remote-control@${target}…`);
702
+ printInfo(`Downloading @jian-ai/remote-control@${target}…`);
464
703
  scriptBytes = fetchNpmWorkerScript(target);
465
704
  installedVersion = readEmbeddedVersion(scriptBytes.toString('utf8'));
466
705
  }
@@ -477,15 +716,19 @@ async function update() {
477
716
  { stdio: 'inherit', windowsHide: true },
478
717
  );
479
718
  if (npmInstall.status !== 0) {
480
- console.log(
719
+ printInfo(
481
720
  'Global npm install skipped or failed; the service still uses ~/.config/jian-remote/jian-remote.mjs.',
482
721
  );
483
722
  }
484
723
 
485
- await startOrRestartService(script);
486
- console.log(
487
- `Updated Jian Remote to ${installedVersion}${gatewayVersion ? ` (gateway ${gatewayVersion})` : ''}. Worker restarted.`,
488
- );
724
+ const started = await startOrRestartService(script);
725
+ const facts = [
726
+ ['Version', installedVersion],
727
+ ['Service', `${started.service} restarted`],
728
+ ];
729
+ if (gatewayVersion) facts.push(['Gateway', gatewayVersion]);
730
+ if (started.detail) facts.push(['Identity', started.detail]);
731
+ printSuccess('Updated Jian Remote', facts);
489
732
  }
490
733
 
491
734
  async function execute(task) {
@@ -502,6 +745,7 @@ async function execute(task) {
502
745
  });
503
746
  let stdout = '';
504
747
  let stderr = '';
748
+ let stdoutSeen = 0;
505
749
  let timedOut = false;
506
750
  const timeout = setTimeout(() => {
507
751
  timedOut = true;
@@ -518,6 +762,7 @@ async function execute(task) {
518
762
  }, 2000).unref();
519
763
  }, task.timeoutMs);
520
764
  child.stdout.on('data', (chunk) => {
765
+ stdoutSeen += chunk.toString('utf8').length;
521
766
  stdout = cappedAppend(stdout, chunk, STDOUT_CAP);
522
767
  });
523
768
  child.stderr.on('data', (chunk) => {
@@ -535,6 +780,14 @@ async function execute(task) {
535
780
  });
536
781
  child.on('close', (code) => {
537
782
  clearTimeout(timeout);
783
+ // Mark silent stdout cuts so screenshot tools can reject grey/truncated JPEGs.
784
+ if (stdoutSeen > STDOUT_CAP) {
785
+ stderr = cappedAppend(
786
+ stderr,
787
+ '\n[jian-remote] stdout truncated at the worker channel cap\n',
788
+ STDERR_CAP,
789
+ );
790
+ }
538
791
  resolveResult({ taskId: task.id, exitCode: code, stdout, stderr, timedOut });
539
792
  });
540
793
  });
@@ -707,20 +960,45 @@ async function run() {
707
960
  }
708
961
  }
709
962
 
710
- const invokedAsCli =
711
- Boolean(process.argv[1]) && resolve(fileURLToPath(import.meta.url)) === resolve(process.argv[1]);
963
+ /**
964
+ * True when this file is the process entry. npm’s `.bin/jian-remote` is a symlink; comparing
965
+ * resolve() paths alone misses that and made `npx … install` exit 0 with no stdout/stderr.
966
+ */
967
+ export function isCliEntry(argv1 = process.argv[1], metaUrl = import.meta.url) {
968
+ if (!argv1) return false;
969
+ try {
970
+ return metaUrl === pathToFileURL(realpathSync(argv1)).href;
971
+ } catch {
972
+ return resolve(fileURLToPath(metaUrl)) === resolve(argv1);
973
+ }
974
+ }
712
975
 
713
- if (invokedAsCli) {
976
+ if (isCliEntry()) {
714
977
  try {
715
978
  if (args[0] === 'install') await install();
979
+ else if (args[0] === 'configure') await configure();
716
980
  else if (args[0] === 'update') await update();
717
981
  else if (args[0] === 'run') await run();
718
- else
719
- console.log(
720
- 'Jian Remote worker (@jian-ai/remote-control)\n install --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 …\nUpdate: npx @jian-ai/remote-control@latest update\n (reads ~/.config/jian-remote; no token/machine/gateway flags)',
721
- );
982
+ else {
983
+ const color = shouldColorize();
984
+ const title = paint('Jian Remote', ANSI.bold, color);
985
+ const pkg = paint('(@jian-ai/remote-control)', ANSI.dim, color);
986
+ console.log(`${title} worker ${pkg}
987
+ install --gateway HTTPS_URL --machine UUID (--token VALUE | --token-prompt | --token-stdin)
988
+ configure [--gateway HTTPS_URL] [--machine UUID] (--token VALUE | --token-prompt | --token-stdin)
989
+ update [--version X.Y.Z|latest]
990
+ run
991
+
992
+ Install: npm install -g @jian-ai/remote-control
993
+ Or: npx @jian-ai/remote-control@latest install --gateway … --machine … --token …
994
+ Configure: jian-remote configure --token … (or --gateway / --machine; restarts the service)
995
+ Update: npx @jian-ai/remote-control@latest update
996
+ (upgrades the worker binary; reads ~/.config/jian-remote)`);
997
+ }
722
998
  } catch (error) {
723
- console.error(error instanceof Error ? error.message : error);
999
+ const color = shouldColorize({ isTTY: process.stderr.isTTY });
1000
+ const prefix = paint('Error', ANSI.red, color);
1001
+ console.error(`${prefix}: ${error instanceof Error ? error.message : error}`);
724
1002
  process.exitCode = 1;
725
1003
  }
726
1004
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jian-ai/remote-control",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Jian Remote Control worker — connect a computer to a Jian gateway over HTTPS",
5
5
  "type": "module",
6
6
  "bin": {