@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 +10 -0
- package/bin/jian-remote.mjs +363 -85
- package/package.json +1 -1
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).
|
package/bin/jian-remote.mjs
CHANGED
|
@@ -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.
|
|
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() : '';
|
|
@@ -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
|
-
/**
|
|
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('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
323
|
-
}
|
|
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
|
-
|
|
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
|
|
365
|
-
const
|
|
366
|
-
|
|
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
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
);
|
|
407
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
);
|
|
669
|
+
const release = parseRelease(gatewayVersion);
|
|
670
|
+
facts.push(['Gateway', `${gatewayVersion} (major.minor ${release?.major}.${release?.minor})`]);
|
|
427
671
|
}
|
|
428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
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
|
-
|
|
711
|
-
|
|
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 (
|
|
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
|
-
|
|
720
|
-
|
|
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
|
-
|
|
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
|
}
|