@jian-ai/remote-control 0.7.1 → 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.
Files changed (2) hide show
  1. package/bin/jian-remote.mjs +231 -44
  2. package/package.json +1 -1
@@ -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.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() : '';
@@ -340,26 +484,38 @@ async function materializeWorkerScript(sourceBytes) {
340
484
  return installedScript;
341
485
  }
342
486
 
343
- /** 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
+ */
344
492
  async function startOrRestartService(script) {
345
493
  if (process.platform === 'darwin') {
346
494
  const launchAgents = join(homedir(), 'Library', 'LaunchAgents');
347
495
  const plistPath = join(launchAgents, 'com.jian.remote.plist');
348
- const xml = (value) =>
349
- value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
350
496
  const label = 'com.jian.remote';
497
+ const app = await materializeMacosAppRuntime();
351
498
  await mkdir(launchAgents, { recursive: true, mode: 0o700 });
352
499
  await writeFile(
353
500
  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`,
501
+ buildLaunchAgentPlist({
502
+ label,
503
+ executable: app.executable,
504
+ script,
505
+ logPath: join(configDir, 'worker.log'),
506
+ }),
355
507
  { mode: 0o600 },
356
508
  );
357
509
  const domain = `gui/${process.getuid()}`;
358
510
  spawnSync('launchctl', ['bootout', domain, plistPath], { stdio: 'ignore' });
359
511
  const loaded = spawnSync('launchctl', ['bootstrap', domain, plistPath], { stdio: 'inherit' });
360
512
  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') {
513
+ return {
514
+ service: 'LaunchAgent',
515
+ detail: `Login Items identity: ${MACOS_APP_NAME}.app (${app.appRoot})`,
516
+ };
517
+ }
518
+ if (process.platform === 'linux') {
363
519
  const unitDir = join(homedir(), '.config', 'systemd', 'user');
364
520
  await mkdir(unitDir, { recursive: true, mode: 0o700 });
365
521
  const quote = (value) => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
@@ -374,8 +530,9 @@ async function startOrRestartService(script) {
374
530
  if (result.status !== 0)
375
531
  throw new Error('Could not install/start Jian Remote with systemd --user');
376
532
  }
377
- console.log('Installed and started the per-user systemd service.');
378
- } else if (process.platform === 'win32') {
533
+ return { service: 'systemd --user', detail: 'jian-remote.service' };
534
+ }
535
+ if (process.platform === 'win32') {
379
536
  const action = `"${process.execPath}" "${script}" run`;
380
537
  spawnSync('schtasks.exe', ['/End', '/TN', 'Jian Remote'], {
381
538
  stdio: 'ignore',
@@ -403,12 +560,10 @@ async function startOrRestartService(script) {
403
560
  ['/Run', '/TN', 'Jian Remote'],
404
561
  'Could not start the Jian Remote scheduled task',
405
562
  );
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
- );
563
+ return { service: 'Task Scheduler', detail: 'Jian Remote (ONLOGON)' };
411
564
  }
565
+ printInfo(`Service auto-install is unsupported on ${process.platform}. Run: node ${script} run`);
566
+ return { service: 'none', detail: process.platform };
412
567
  }
413
568
 
414
569
  async function install() {
@@ -431,21 +586,21 @@ async function install() {
431
586
  };
432
587
  await writeConfigFile(config);
433
588
  const script = await materializeWorkerScript();
434
- await startOrRestartService(script);
435
- console.log(`Credentials are stored with owner-only permissions in ${configPath}.`);
589
+ const started = await startOrRestartService(script);
590
+ const facts = [
591
+ ['Service', `${started.service} started`],
592
+ ['Config', configPath],
593
+ ];
436
594
  if (gatewayVersion) {
437
- console.log(
438
- `Compatible with gateway ${gatewayVersion} (major.minor ${parseRelease(gatewayVersion)?.major}.${parseRelease(gatewayVersion)?.minor}).`,
439
- );
440
- }
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.',
595
+ const release = parseRelease(gatewayVersion);
596
+ facts.push(['Gateway', `${gatewayVersion} (major.minor ${release?.major}.${release?.minor})`]);
597
+ }
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',
449
604
  );
450
605
  }
451
606
 
@@ -498,19 +653,23 @@ async function configure() {
498
653
  } catch {
499
654
  script = await materializeWorkerScript();
500
655
  }
501
- await startOrRestartService(script);
656
+ const started = await startOrRestartService(script);
502
657
 
503
658
  const changed = [
504
659
  gatewayFlag ? 'gateway' : null,
505
660
  machineFlag ? 'machine' : null,
506
661
  tokenFlag ? 'token' : null,
507
662
  ].filter(Boolean);
508
- console.log(`Updated ${changed.join(', ')} in ${configPath}. Worker service restarted.`);
663
+ const facts = [
664
+ ['Changed', changed.join(', ')],
665
+ ['Config', configPath],
666
+ ['Service', `${started.service} restarted`],
667
+ ];
509
668
  if (gatewayVersion) {
510
- console.log(
511
- `Compatible with gateway ${gatewayVersion} (major.minor ${parseRelease(gatewayVersion)?.major}.${parseRelease(gatewayVersion)?.minor}).`,
512
- );
669
+ const release = parseRelease(gatewayVersion);
670
+ facts.push(['Gateway', `${gatewayVersion} (major.minor ${release?.major}.${release?.minor})`]);
513
671
  }
672
+ printSuccess('Configured Jian Remote', facts);
514
673
  }
515
674
 
516
675
  /**
@@ -540,7 +699,7 @@ async function update() {
540
699
  scriptBytes = await readFile(fileURLToPath(import.meta.url));
541
700
  installedVersion = PACKAGE_VERSION;
542
701
  } else {
543
- console.log(`Downloading @jian-ai/remote-control@${target}…`);
702
+ printInfo(`Downloading @jian-ai/remote-control@${target}…`);
544
703
  scriptBytes = fetchNpmWorkerScript(target);
545
704
  installedVersion = readEmbeddedVersion(scriptBytes.toString('utf8'));
546
705
  }
@@ -557,15 +716,19 @@ async function update() {
557
716
  { stdio: 'inherit', windowsHide: true },
558
717
  );
559
718
  if (npmInstall.status !== 0) {
560
- console.log(
719
+ printInfo(
561
720
  'Global npm install skipped or failed; the service still uses ~/.config/jian-remote/jian-remote.mjs.',
562
721
  );
563
722
  }
564
723
 
565
- await startOrRestartService(script);
566
- console.log(
567
- `Updated Jian Remote to ${installedVersion}${gatewayVersion ? ` (gateway ${gatewayVersion})` : ''}. Worker restarted.`,
568
- );
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);
569
732
  }
570
733
 
571
734
  async function execute(task) {
@@ -582,6 +745,7 @@ async function execute(task) {
582
745
  });
583
746
  let stdout = '';
584
747
  let stderr = '';
748
+ let stdoutSeen = 0;
585
749
  let timedOut = false;
586
750
  const timeout = setTimeout(() => {
587
751
  timedOut = true;
@@ -598,6 +762,7 @@ async function execute(task) {
598
762
  }, 2000).unref();
599
763
  }, task.timeoutMs);
600
764
  child.stdout.on('data', (chunk) => {
765
+ stdoutSeen += chunk.toString('utf8').length;
601
766
  stdout = cappedAppend(stdout, chunk, STDOUT_CAP);
602
767
  });
603
768
  child.stderr.on('data', (chunk) => {
@@ -615,6 +780,14 @@ async function execute(task) {
615
780
  });
616
781
  child.on('close', (code) => {
617
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
+ }
618
791
  resolveResult({ taskId: task.id, exitCode: code, stdout, stderr, timedOut });
619
792
  });
620
793
  });
@@ -806,12 +979,26 @@ if (isCliEntry()) {
806
979
  else if (args[0] === 'configure') await configure();
807
980
  else if (args[0] === 'update') await update();
808
981
  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
- );
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
+ }
813
998
  } catch (error) {
814
- 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}`);
815
1002
  process.exitCode = 1;
816
1003
  }
817
1004
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jian-ai/remote-control",
3
- "version": "0.7.1",
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": {