@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.
- package/bin/jian-remote.mjs +231 -44
- package/package.json +1 -1
package/bin/jian-remote.mjs
CHANGED
|
@@ -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.
|
|
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('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
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
|
-
/**
|
|
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('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
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
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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
|
-
|
|
378
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
589
|
+
const started = await startOrRestartService(script);
|
|
590
|
+
const facts = [
|
|
591
|
+
['Service', `${started.service} started`],
|
|
592
|
+
['Config', configPath],
|
|
593
|
+
];
|
|
436
594
|
if (gatewayVersion) {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
);
|
|
444
|
-
|
|
445
|
-
'
|
|
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
|
-
|
|
663
|
+
const facts = [
|
|
664
|
+
['Changed', changed.join(', ')],
|
|
665
|
+
['Config', configPath],
|
|
666
|
+
['Service', `${started.service} restarted`],
|
|
667
|
+
];
|
|
509
668
|
if (gatewayVersion) {
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
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
|
-
|
|
811
|
-
|
|
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
|
-
|
|
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
|
}
|