@jian-ai/remote-control 0.0.0-stage → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/bin/jian-remote.mjs +726 -0
- package/package.json +40 -4
package/README.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@jian-ai/remote-control`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Jian Remote Control worker. Connects a Windows, macOS, or Linux computer to a Jian gateway
|
|
4
|
+
over outbound HTTPS polling.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install -g @jian-ai/remote-control
|
|
10
|
+
jian-remote install --gateway https://gateway.example --machine MACHINE_UUID --token WORKER_TOKEN
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or without a global install:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npx @jian-ai/remote-control@latest install \
|
|
17
|
+
--gateway https://gateway.example \
|
|
18
|
+
--machine MACHINE_UUID \
|
|
19
|
+
--token WORKER_TOKEN
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Update
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npx @jian-ai/remote-control@latest update
|
|
26
|
+
# or: jian-remote update [--version 0.6.0]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Reads `~/.config/jian-remote` (no token, machine id, or gateway flags), installs a compatible
|
|
30
|
+
build, and restarts the worker service.
|
|
31
|
+
|
|
32
|
+
## Compatibility
|
|
33
|
+
|
|
34
|
+
Worker and gateway must share the same major.minor. See [docs/remote.md](../../docs/remote.md).
|
|
35
|
+
|
|
36
|
+
Requires Node.js 24+.
|
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
5
|
+
import { chmod, copyFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { homedir, tmpdir } from 'node:os';
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const configDir = join(homedir(), '.config', 'jian-remote');
|
|
11
|
+
const configPath = join(configDir, 'config.json');
|
|
12
|
+
/** Stable path the OS service runs — not the npx cache or a transient install location. */
|
|
13
|
+
const installedScript = join(configDir, 'jian-remote.mjs');
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const option = (name) => {
|
|
16
|
+
const index = args.indexOf(name);
|
|
17
|
+
return index < 0 ? undefined : args[index + 1];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Stamped to the release tag on npm publish and when the gateway serves this script.
|
|
22
|
+
* Compatibility with the gateway requires the same major.minor (see versionsCompatible).
|
|
23
|
+
*/
|
|
24
|
+
export const PACKAGE_VERSION = '0.7.0';
|
|
25
|
+
|
|
26
|
+
/** Soft stdout/stderr caps while capturing a command (chars). Gateway schema matches these. */
|
|
27
|
+
const STDOUT_CAP = 400_000;
|
|
28
|
+
const STDERR_CAP = 50_000;
|
|
29
|
+
/**
|
|
30
|
+
* Max JSON body for a result POST. The gateway route allows 512 KiB; leave headroom for
|
|
31
|
+
* framing so a full screenshot still fits without a 413.
|
|
32
|
+
*/
|
|
33
|
+
const RESULT_BODY_BUDGET = 480_000;
|
|
34
|
+
/** Permanent HTTP statuses — retrying forever leaves the task stuck and the machine offline. */
|
|
35
|
+
const PERMANENT_RESULT_STATUS = new Set([400, 401, 403, 404, 413]);
|
|
36
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
|
37
|
+
|
|
38
|
+
export function parseRelease(version) {
|
|
39
|
+
const raw = typeof version === 'string' ? version.replace(/^v/, '').trim() : '';
|
|
40
|
+
const match = SEMVER.exec(raw);
|
|
41
|
+
if (!match) return null;
|
|
42
|
+
return {
|
|
43
|
+
major: Number(match[1]),
|
|
44
|
+
minor: Number(match[2]),
|
|
45
|
+
patch: Number(match[3]),
|
|
46
|
+
prerelease: match[4],
|
|
47
|
+
raw,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isDevVersion(version) {
|
|
52
|
+
const raw = typeof version === 'string' ? version.replace(/^v/, '').trim() : '';
|
|
53
|
+
return !raw || raw === '0.0.0-dev' || raw === '0.0.0';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Rule: a released remote-control may talk to a released gateway only when major.minor match
|
|
58
|
+
* (patch and prerelease may differ). A gateway with no version (local `make dev`) skips the
|
|
59
|
+
* gate so developers are not blocked. A released gateway rejects a dev/unknown worker.
|
|
60
|
+
*/
|
|
61
|
+
export function versionsCompatible(localVersion, gatewayVersion) {
|
|
62
|
+
if (isDevVersion(gatewayVersion)) {
|
|
63
|
+
return { ok: true, skipped: true };
|
|
64
|
+
}
|
|
65
|
+
const gateway = parseRelease(gatewayVersion);
|
|
66
|
+
if (!gateway) return { ok: true, skipped: true };
|
|
67
|
+
|
|
68
|
+
if (isDevVersion(localVersion)) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
skipped: false,
|
|
72
|
+
local: localVersion ?? 'unknown',
|
|
73
|
+
gateway: gateway.raw,
|
|
74
|
+
required: `${gateway.major}.${gateway.minor}`,
|
|
75
|
+
message: `This remote-control build (${localVersion ?? 'unknown'}) is not a released package and cannot connect to gateway ${gateway.raw}. Install a matching release: npx @jian-ai/remote-control@${gateway.raw} update`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const local = parseRelease(localVersion);
|
|
79
|
+
if (!local) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
skipped: false,
|
|
83
|
+
local: localVersion ?? 'unknown',
|
|
84
|
+
gateway: gateway.raw,
|
|
85
|
+
required: `${gateway.major}.${gateway.minor}`,
|
|
86
|
+
message: `This remote-control version (${localVersion ?? 'unknown'}) is not a valid release and cannot connect to gateway ${gateway.raw}.`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (local.major === gateway.major && local.minor === gateway.minor) {
|
|
90
|
+
return { ok: true, skipped: false, local: local.raw, gateway: gateway.raw };
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
skipped: false,
|
|
95
|
+
local: local.raw,
|
|
96
|
+
gateway: gateway.raw,
|
|
97
|
+
required: `${gateway.major}.${gateway.minor}`,
|
|
98
|
+
message: `This remote-control ${local.raw} is incompatible with gateway ${gateway.raw}. Jian requires the same major.minor (${gateway.major}.${gateway.minor}). Run: npx @jian-ai/remote-control@${gateway.raw} update`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function compatibilityError(localVersion, gatewayVersion) {
|
|
103
|
+
const result = versionsCompatible(localVersion, gatewayVersion);
|
|
104
|
+
return result.ok ? null : result.message;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function cappedAppend(current, chunk, cap = STDOUT_CAP) {
|
|
108
|
+
return (current + chunk.toString('utf8')).slice(0, cap);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Shrink stdout/stderr until the JSON body fits under budget (UTF-8 byte length). */
|
|
112
|
+
export function fitResult(result, budget = RESULT_BODY_BUDGET) {
|
|
113
|
+
let stdout = result.stdout ?? '';
|
|
114
|
+
let stderr = result.stderr ?? '';
|
|
115
|
+
const pack = () =>
|
|
116
|
+
JSON.stringify({
|
|
117
|
+
taskId: result.taskId,
|
|
118
|
+
exitCode: result.exitCode,
|
|
119
|
+
stdout,
|
|
120
|
+
stderr,
|
|
121
|
+
timedOut: Boolean(result.timedOut),
|
|
122
|
+
});
|
|
123
|
+
let body = pack();
|
|
124
|
+
if (Buffer.byteLength(body) <= budget) {
|
|
125
|
+
return { result: { ...result, stdout, stderr, timedOut: Boolean(result.timedOut) }, body };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const note = '\n… truncated to fit the gateway size limit';
|
|
129
|
+
while (Buffer.byteLength(body) > budget && stdout.length > 0) {
|
|
130
|
+
const overflow = Buffer.byteLength(body) - budget + Buffer.byteLength(note);
|
|
131
|
+
const cut = Math.min(stdout.length, Math.max(2048, overflow));
|
|
132
|
+
stdout = stdout.slice(0, Math.max(0, stdout.length - cut));
|
|
133
|
+
body = pack();
|
|
134
|
+
}
|
|
135
|
+
if (stdout.length < (result.stdout?.length ?? 0))
|
|
136
|
+
stdout = `${stdout}${note}`.slice(0, STDOUT_CAP);
|
|
137
|
+
|
|
138
|
+
body = pack();
|
|
139
|
+
while (Buffer.byteLength(body) > budget && stderr.length > 0) {
|
|
140
|
+
const overflow = Buffer.byteLength(body) - budget + Buffer.byteLength(note);
|
|
141
|
+
const cut = Math.min(stderr.length, Math.max(1024, overflow));
|
|
142
|
+
stderr = stderr.slice(0, Math.max(0, stderr.length - cut));
|
|
143
|
+
body = pack();
|
|
144
|
+
}
|
|
145
|
+
if (stderr.length < (result.stderr?.length ?? 0))
|
|
146
|
+
stderr = `${stderr}${note}`.slice(0, STDERR_CAP);
|
|
147
|
+
|
|
148
|
+
body = pack();
|
|
149
|
+
if (Buffer.byteLength(body) > budget) {
|
|
150
|
+
// Last resort: drop payloads so the agent unblocks instead of spinning on 413.
|
|
151
|
+
stdout = '';
|
|
152
|
+
stderr = 'Result omitted: payload exceeded the gateway size limit after truncation.';
|
|
153
|
+
body = pack();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
result: { ...result, stdout, stderr, timedOut: Boolean(result.timedOut) },
|
|
158
|
+
body,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function readStdin() {
|
|
163
|
+
let value = '';
|
|
164
|
+
for await (const chunk of process.stdin) value += chunk.toString('utf8');
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function readSecret(prompt) {
|
|
169
|
+
if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== 'function') {
|
|
170
|
+
throw new Error('Secret prompt requires an interactive terminal; use --token-stdin instead');
|
|
171
|
+
}
|
|
172
|
+
process.stdout.write(prompt);
|
|
173
|
+
process.stdin.setRawMode(true);
|
|
174
|
+
process.stdin.setEncoding('utf8');
|
|
175
|
+
process.stdin.resume();
|
|
176
|
+
return await new Promise((resolve, reject) => {
|
|
177
|
+
let value = '';
|
|
178
|
+
const finish = (error) => {
|
|
179
|
+
process.stdin.setRawMode(false);
|
|
180
|
+
process.stdin.pause();
|
|
181
|
+
process.stdin.off('data', onData);
|
|
182
|
+
process.stdout.write('\n');
|
|
183
|
+
if (error) reject(error);
|
|
184
|
+
else resolve(value);
|
|
185
|
+
};
|
|
186
|
+
const onData = (chunk) => {
|
|
187
|
+
for (const char of chunk) {
|
|
188
|
+
if (char === '\u0003') return finish(new Error('Installation cancelled'));
|
|
189
|
+
if (char === '\r' || char === '\n') return finish();
|
|
190
|
+
if (char === '\u007f' || char === '\b') value = value.slice(0, -1);
|
|
191
|
+
else value += char;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
process.stdin.on('data', onData);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function runChecked(command, commandArgs, message) {
|
|
199
|
+
const result = spawnSync(command, commandArgs, { stdio: 'inherit', windowsHide: true });
|
|
200
|
+
if (result.status !== 0) throw new Error(message);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function loadConfig() {
|
|
204
|
+
try {
|
|
205
|
+
return JSON.parse(await readFile(configPath, 'utf8'));
|
|
206
|
+
} catch {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`No Jian Remote config at ${configPath}. Run install first (from the panel), or: jian-remote install --gateway https://… --machine UUID --token …`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Public /health carries the gateway release so install/update can gate without a worker token. */
|
|
214
|
+
export async function fetchGatewayVersion(gatewayBase, fetcher = fetch) {
|
|
215
|
+
const response = await fetcher(`${gatewayBase.replace(/\/$/, '')}/health`, {
|
|
216
|
+
redirect: 'error',
|
|
217
|
+
signal: AbortSignal.timeout(15_000),
|
|
218
|
+
});
|
|
219
|
+
if (!response.ok) throw new Error(`Could not read gateway version: HTTP ${response.status}`);
|
|
220
|
+
const body = await response.json();
|
|
221
|
+
return typeof body?.version === 'string' && body.version.trim() ? body.version.trim() : undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function assertCompatibleWithGateway(gatewayBase, localVersion = PACKAGE_VERSION) {
|
|
225
|
+
const gatewayVersion = await fetchGatewayVersion(gatewayBase);
|
|
226
|
+
const error = compatibilityError(localVersion, gatewayVersion);
|
|
227
|
+
if (error) throw new Error(error);
|
|
228
|
+
return gatewayVersion;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function readEmbeddedVersion(scriptSource) {
|
|
232
|
+
const match = /export const PACKAGE_VERSION = '([^']+)'/.exec(scriptSource);
|
|
233
|
+
return match?.[1] ?? 'unknown';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Pull a specific npm release’s worker script into a temp path (for update when self is wrong). */
|
|
237
|
+
function fetchNpmWorkerScript(version) {
|
|
238
|
+
const dir = mkdtempSync(join(tmpdir(), 'jian-remote-'));
|
|
239
|
+
try {
|
|
240
|
+
const packed = spawnSync(
|
|
241
|
+
'npm',
|
|
242
|
+
['pack', `@jian-ai/remote-control@${version}`, '--pack-destination', dir],
|
|
243
|
+
{ encoding: 'utf8', windowsHide: true },
|
|
244
|
+
);
|
|
245
|
+
if (packed.status !== 0) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
packed.stderr?.trim() ||
|
|
248
|
+
`Could not download @jian-ai/remote-control@${version} from npm. Check the version and network.`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const tarball = packed.stdout
|
|
252
|
+
.trim()
|
|
253
|
+
.split(/\r?\n/)
|
|
254
|
+
.map((line) => line.trim())
|
|
255
|
+
.filter(Boolean)
|
|
256
|
+
.at(-1);
|
|
257
|
+
if (!tarball)
|
|
258
|
+
throw new Error(`npm pack produced no tarball for @jian-ai/remote-control@${version}`);
|
|
259
|
+
const tarballPath = join(dir, tarball);
|
|
260
|
+
const extracted = spawnSync('tar', ['-xzf', tarballPath, '-C', dir], {
|
|
261
|
+
encoding: 'utf8',
|
|
262
|
+
windowsHide: true,
|
|
263
|
+
});
|
|
264
|
+
if (extracted.status !== 0) {
|
|
265
|
+
throw new Error(extracted.stderr?.trim() || 'Could not extract the remote-control package');
|
|
266
|
+
}
|
|
267
|
+
return readFileSync(join(dir, 'package', 'bin', 'jian-remote.mjs'));
|
|
268
|
+
} finally {
|
|
269
|
+
rmSync(dir, { recursive: true, force: true });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Copy a worker script into ~/.config so npx and global installs share one service path. */
|
|
274
|
+
async function materializeWorkerScript(sourceBytes) {
|
|
275
|
+
await mkdir(configDir, { recursive: true, mode: 0o700 });
|
|
276
|
+
if (sourceBytes) {
|
|
277
|
+
const temp = `${installedScript}.${process.pid}.tmp`;
|
|
278
|
+
await writeFile(temp, sourceBytes, { mode: 0o700 });
|
|
279
|
+
await rename(temp, installedScript);
|
|
280
|
+
} else {
|
|
281
|
+
const source = fileURLToPath(import.meta.url);
|
|
282
|
+
if (source !== installedScript) await copyFile(source, installedScript);
|
|
283
|
+
}
|
|
284
|
+
if (process.platform !== 'win32') await chmod(installedScript, 0o700);
|
|
285
|
+
return installedScript;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Register or bounce the per-user service so it runs the installed script and reconnects. */
|
|
289
|
+
async function startOrRestartService(script) {
|
|
290
|
+
if (process.platform === 'darwin') {
|
|
291
|
+
const launchAgents = join(homedir(), 'Library', 'LaunchAgents');
|
|
292
|
+
const plistPath = join(launchAgents, 'com.jian.remote.plist');
|
|
293
|
+
const xml = (value) =>
|
|
294
|
+
value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
295
|
+
const label = 'com.jian.remote';
|
|
296
|
+
await mkdir(launchAgents, { recursive: true, mode: 0o700 });
|
|
297
|
+
await writeFile(
|
|
298
|
+
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`,
|
|
300
|
+
{ mode: 0o600 },
|
|
301
|
+
);
|
|
302
|
+
const domain = `gui/${process.getuid()}`;
|
|
303
|
+
spawnSync('launchctl', ['bootout', domain, plistPath], { stdio: 'ignore' });
|
|
304
|
+
const loaded = spawnSync('launchctl', ['bootstrap', domain, plistPath], { stdio: 'inherit' });
|
|
305
|
+
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') {
|
|
308
|
+
const unitDir = join(homedir(), '.config', 'systemd', 'user');
|
|
309
|
+
await mkdir(unitDir, { recursive: true, mode: 0o700 });
|
|
310
|
+
const quote = (value) => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
311
|
+
const unit = `[Unit]\nDescription=Jian Remote worker\nAfter=network-online.target\n\n[Service]\nExecStart=${quote(process.execPath)} ${quote(script)} run\nRestart=always\nRestartSec=3\n\n[Install]\nWantedBy=default.target\n`;
|
|
312
|
+
await writeFile(join(unitDir, 'jian-remote.service'), unit, { mode: 0o600 });
|
|
313
|
+
for (const command of [
|
|
314
|
+
['daemon-reload'],
|
|
315
|
+
['enable', '--now', 'jian-remote.service'],
|
|
316
|
+
['restart', 'jian-remote.service'],
|
|
317
|
+
]) {
|
|
318
|
+
const result = spawnSync('systemctl', ['--user', ...command], { stdio: 'inherit' });
|
|
319
|
+
if (result.status !== 0)
|
|
320
|
+
throw new Error('Could not install/start Jian Remote with systemd --user');
|
|
321
|
+
}
|
|
322
|
+
console.log('Installed and started the per-user systemd service.');
|
|
323
|
+
} else if (process.platform === 'win32') {
|
|
324
|
+
const action = `"${process.execPath}" "${script}" run`;
|
|
325
|
+
spawnSync('schtasks.exe', ['/End', '/TN', 'Jian Remote'], {
|
|
326
|
+
stdio: 'ignore',
|
|
327
|
+
windowsHide: true,
|
|
328
|
+
});
|
|
329
|
+
runChecked(
|
|
330
|
+
'schtasks.exe',
|
|
331
|
+
[
|
|
332
|
+
'/Create',
|
|
333
|
+
'/SC',
|
|
334
|
+
'ONLOGON',
|
|
335
|
+
'/TN',
|
|
336
|
+
'Jian Remote',
|
|
337
|
+
'/TR',
|
|
338
|
+
action,
|
|
339
|
+
'/F',
|
|
340
|
+
'/RL',
|
|
341
|
+
'LIMITED',
|
|
342
|
+
'/IT',
|
|
343
|
+
],
|
|
344
|
+
'Could not install the Jian Remote logon task with Task Scheduler',
|
|
345
|
+
);
|
|
346
|
+
runChecked(
|
|
347
|
+
'schtasks.exe',
|
|
348
|
+
['/Run', '/TN', 'Jian Remote'],
|
|
349
|
+
'Could not start the Jian Remote scheduled task',
|
|
350
|
+
);
|
|
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
|
+
);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function install() {
|
|
360
|
+
const gateway = option('--gateway');
|
|
361
|
+
const machineId = option('--machine');
|
|
362
|
+
// Prefer an explicit --token for one-liner installs from the panel; stdin/prompt keep
|
|
363
|
+
// 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
|
+
) {
|
|
387
|
+
throw new Error(
|
|
388
|
+
'Usage: jian-remote install --gateway https://... --machine UUID (--token VALUE | --token-prompt | --token-stdin)',
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
const gatewayBase = gatewayUrl.origin + gatewayUrl.pathname.replace(/\/$/, '');
|
|
392
|
+
const gatewayVersion = await assertCompatibleWithGateway(gatewayBase);
|
|
393
|
+
const config = {
|
|
394
|
+
gateway: gatewayBase,
|
|
395
|
+
machineId,
|
|
396
|
+
token: token.trim(),
|
|
397
|
+
};
|
|
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);
|
|
409
|
+
}
|
|
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',
|
|
416
|
+
);
|
|
417
|
+
} else {
|
|
418
|
+
await chmod(configPath, 0o600);
|
|
419
|
+
}
|
|
420
|
+
const script = await materializeWorkerScript();
|
|
421
|
+
await startOrRestartService(script);
|
|
422
|
+
console.log(`Credentials are stored with owner-only permissions in ${configPath}.`);
|
|
423
|
+
if (gatewayVersion) {
|
|
424
|
+
console.log(
|
|
425
|
+
`Compatible with gateway ${gatewayVersion} (major.minor ${parseRelease(gatewayVersion)?.major}.${parseRelease(gatewayVersion)?.minor}).`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
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
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Refresh the installed worker from npm (or this process when already the right build), using
|
|
438
|
+
* credentials already stored under ~/.config/jian-remote — never re-ask for token/machine/gateway.
|
|
439
|
+
*/
|
|
440
|
+
async function update() {
|
|
441
|
+
const config = await loadConfig();
|
|
442
|
+
if (typeof config.gateway !== 'string' || !config.gateway.startsWith('https://')) {
|
|
443
|
+
throw new Error(`Invalid gateway in ${configPath}; re-run install from the panel.`);
|
|
444
|
+
}
|
|
445
|
+
const gatewayVersion = await fetchGatewayVersion(config.gateway);
|
|
446
|
+
const requested = option('--version');
|
|
447
|
+
const target = requested?.trim() || gatewayVersion || 'latest';
|
|
448
|
+
|
|
449
|
+
let scriptBytes;
|
|
450
|
+
let installedVersion = PACKAGE_VERSION;
|
|
451
|
+
// When this process already matches the target (typical: npx @jian-ai/remote-control@X update),
|
|
452
|
+
// copy ourselves. Otherwise pull the exact package from npm.
|
|
453
|
+
const selfMatchesTarget =
|
|
454
|
+
target === 'latest'
|
|
455
|
+
? !isDevVersion(PACKAGE_VERSION)
|
|
456
|
+
: PACKAGE_VERSION === target.replace(/^v/, '') ||
|
|
457
|
+
versionsCompatible(PACKAGE_VERSION, target).ok;
|
|
458
|
+
|
|
459
|
+
if (selfMatchesTarget && !isDevVersion(PACKAGE_VERSION)) {
|
|
460
|
+
scriptBytes = await readFile(fileURLToPath(import.meta.url));
|
|
461
|
+
installedVersion = PACKAGE_VERSION;
|
|
462
|
+
} else {
|
|
463
|
+
console.log(`Downloading @jian-ai/remote-control@${target}…`);
|
|
464
|
+
scriptBytes = fetchNpmWorkerScript(target);
|
|
465
|
+
installedVersion = readEmbeddedVersion(scriptBytes.toString('utf8'));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const error = compatibilityError(installedVersion, gatewayVersion);
|
|
469
|
+
if (error) throw new Error(error);
|
|
470
|
+
|
|
471
|
+
const script = await materializeWorkerScript(scriptBytes);
|
|
472
|
+
|
|
473
|
+
// Keep a global install in sync when the operator uses the jian-remote bin from PATH.
|
|
474
|
+
const npmInstall = spawnSync(
|
|
475
|
+
'npm',
|
|
476
|
+
['install', '-g', `@jian-ai/remote-control@${target === 'latest' ? installedVersion : target}`],
|
|
477
|
+
{ stdio: 'inherit', windowsHide: true },
|
|
478
|
+
);
|
|
479
|
+
if (npmInstall.status !== 0) {
|
|
480
|
+
console.log(
|
|
481
|
+
'Global npm install skipped or failed; the service still uses ~/.config/jian-remote/jian-remote.mjs.',
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
await startOrRestartService(script);
|
|
486
|
+
console.log(
|
|
487
|
+
`Updated Jian Remote to ${installedVersion}${gatewayVersion ? ` (gateway ${gatewayVersion})` : ''}. Worker restarted.`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function execute(task) {
|
|
492
|
+
return await new Promise((resolveResult) => {
|
|
493
|
+
const command = process.platform === 'win32' ? 'powershell.exe' : '/bin/sh';
|
|
494
|
+
const commandArgs =
|
|
495
|
+
process.platform === 'win32'
|
|
496
|
+
? ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', task.command]
|
|
497
|
+
: ['-lc', task.command];
|
|
498
|
+
const child = spawn(command, commandArgs, {
|
|
499
|
+
cwd: task.cwd || homedir(),
|
|
500
|
+
env: process.env,
|
|
501
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
502
|
+
});
|
|
503
|
+
let stdout = '';
|
|
504
|
+
let stderr = '';
|
|
505
|
+
let timedOut = false;
|
|
506
|
+
const timeout = setTimeout(() => {
|
|
507
|
+
timedOut = true;
|
|
508
|
+
child.kill('SIGTERM');
|
|
509
|
+
setTimeout(() => {
|
|
510
|
+
if (process.platform === 'win32' && child.pid) {
|
|
511
|
+
spawnSync('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
|
|
512
|
+
stdio: 'ignore',
|
|
513
|
+
windowsHide: true,
|
|
514
|
+
});
|
|
515
|
+
} else {
|
|
516
|
+
child.kill('SIGKILL');
|
|
517
|
+
}
|
|
518
|
+
}, 2000).unref();
|
|
519
|
+
}, task.timeoutMs);
|
|
520
|
+
child.stdout.on('data', (chunk) => {
|
|
521
|
+
stdout = cappedAppend(stdout, chunk, STDOUT_CAP);
|
|
522
|
+
});
|
|
523
|
+
child.stderr.on('data', (chunk) => {
|
|
524
|
+
stderr = cappedAppend(stderr, chunk, STDERR_CAP);
|
|
525
|
+
});
|
|
526
|
+
child.on('error', (error) => {
|
|
527
|
+
clearTimeout(timeout);
|
|
528
|
+
resolveResult({
|
|
529
|
+
taskId: task.id,
|
|
530
|
+
exitCode: null,
|
|
531
|
+
stdout,
|
|
532
|
+
stderr: cappedAppend(stderr, error.message, STDERR_CAP),
|
|
533
|
+
timedOut,
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
child.on('close', (code) => {
|
|
537
|
+
clearTimeout(timeout);
|
|
538
|
+
resolveResult({ taskId: task.id, exitCode: code, stdout, stderr, timedOut });
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function updateIfNeeded(base, headers) {
|
|
544
|
+
// Prefer the installed copy under ~/.config so updates land where the service points.
|
|
545
|
+
const script = installedScript;
|
|
546
|
+
let local;
|
|
547
|
+
try {
|
|
548
|
+
local = await readFile(script);
|
|
549
|
+
} catch {
|
|
550
|
+
local = await readFile(fileURLToPath(import.meta.url));
|
|
551
|
+
}
|
|
552
|
+
const etag = `"${createHash('sha256').update(local).digest('hex')}"`;
|
|
553
|
+
const response = await fetch(`${base}/update`, {
|
|
554
|
+
headers: { ...headers, 'if-none-match': etag },
|
|
555
|
+
redirect: 'error',
|
|
556
|
+
signal: AbortSignal.timeout(15_000),
|
|
557
|
+
});
|
|
558
|
+
if (response.status === 304) return false;
|
|
559
|
+
if (!response.ok) throw new Error(`worker update check failed: HTTP ${response.status}`);
|
|
560
|
+
const updated = Buffer.from(await response.arrayBuffer());
|
|
561
|
+
if (!updated.length || updated.length > 1_000_000) throw new Error('Invalid worker update size');
|
|
562
|
+
await mkdir(configDir, { recursive: true, mode: 0o700 });
|
|
563
|
+
const temp = `${script}.${process.pid}.tmp`;
|
|
564
|
+
await writeFile(temp, updated, { mode: 0o700 });
|
|
565
|
+
await rename(temp, script);
|
|
566
|
+
console.log('Worker updated; restarting to apply it.');
|
|
567
|
+
if (process.platform === 'win32') {
|
|
568
|
+
const child = spawn(process.execPath, [script, 'run'], {
|
|
569
|
+
detached: true,
|
|
570
|
+
stdio: 'ignore',
|
|
571
|
+
windowsHide: true,
|
|
572
|
+
});
|
|
573
|
+
child.unref();
|
|
574
|
+
}
|
|
575
|
+
process.exit(0);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function postResult(base, headers, result) {
|
|
579
|
+
let { result: payload, body } = fitResult(result);
|
|
580
|
+
let failures = 0;
|
|
581
|
+
// full → half → minimal → stop. Never spin forever on 413; that left machines offline.
|
|
582
|
+
let shrinkStage = 'full';
|
|
583
|
+
|
|
584
|
+
while (true) {
|
|
585
|
+
try {
|
|
586
|
+
const saved = await fetch(`${base}/tasks/${result.taskId}/result`, {
|
|
587
|
+
method: 'POST',
|
|
588
|
+
redirect: 'error',
|
|
589
|
+
headers: { ...headers, 'content-type': 'application/json' },
|
|
590
|
+
body,
|
|
591
|
+
signal: AbortSignal.timeout(30_000),
|
|
592
|
+
});
|
|
593
|
+
if (saved.ok) return;
|
|
594
|
+
|
|
595
|
+
if (saved.status === 413) {
|
|
596
|
+
if (shrinkStage === 'full') {
|
|
597
|
+
console.error(
|
|
598
|
+
'result post failed: HTTP 413 (payload too large); truncating and retrying',
|
|
599
|
+
);
|
|
600
|
+
shrinkStage = 'half';
|
|
601
|
+
({ result: payload, body } = fitResult(payload, Math.floor(RESULT_BODY_BUDGET / 2)));
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
if (shrinkStage === 'half') {
|
|
605
|
+
console.error('result still too large after truncation; sending a minimal stub');
|
|
606
|
+
shrinkStage = 'minimal';
|
|
607
|
+
({ result: payload, body } = fitResult(
|
|
608
|
+
{
|
|
609
|
+
...payload,
|
|
610
|
+
stdout: '',
|
|
611
|
+
stderr:
|
|
612
|
+
'Result omitted: gateway rejected the payload as too large (HTTP 413) after truncation.',
|
|
613
|
+
},
|
|
614
|
+
8_000,
|
|
615
|
+
));
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
console.error(
|
|
619
|
+
'result post rejected: HTTP 413 even for a minimal stub. Not retrying. Restart the Jian Remote service or re-run install if the machine stays offline.',
|
|
620
|
+
);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (PERMANENT_RESULT_STATUS.has(saved.status)) {
|
|
625
|
+
console.error(
|
|
626
|
+
`result post rejected: HTTP ${saved.status}. Not retrying. Restart the Jian Remote service or re-run install if the machine stays offline.`,
|
|
627
|
+
);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
throw new Error(`result post failed: HTTP ${saved.status}`);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
failures += 1;
|
|
634
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
635
|
+
console.error(message);
|
|
636
|
+
if (failures >= 40) {
|
|
637
|
+
console.error(
|
|
638
|
+
'Gave up posting this result after repeated failures. The gateway task may stay open until the worker recovers; restart the Jian Remote service if Online stays red.',
|
|
639
|
+
);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const ceiling = Math.min(60_000, 1000 * 2 ** Math.min(failures, 6));
|
|
643
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, Math.random() * ceiling));
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function run() {
|
|
649
|
+
const config = await loadConfig();
|
|
650
|
+
const headers = {
|
|
651
|
+
authorization: `Bearer ${config.token}`,
|
|
652
|
+
'x-jian-platform': `${process.platform}-${process.arch}`,
|
|
653
|
+
'x-jian-remote-version': PACKAGE_VERSION,
|
|
654
|
+
};
|
|
655
|
+
const base = `${config.gateway}/v1/remote/worker/${config.machineId}`;
|
|
656
|
+
// Fail fast before the poll loop so an incompatible build does not look "online then stuck".
|
|
657
|
+
const gatewayVersion = await assertCompatibleWithGateway(config.gateway);
|
|
658
|
+
console.log(
|
|
659
|
+
`Connected to Jian Remote at ${config.gateway} (worker ${PACKAGE_VERSION}${gatewayVersion ? `, gateway ${gatewayVersion}` : ''})`,
|
|
660
|
+
);
|
|
661
|
+
let failures = 0;
|
|
662
|
+
let nextUpdateCheck = 0;
|
|
663
|
+
const delay = async (attempt) => {
|
|
664
|
+
const ceiling = Math.min(60_000, 1000 * 2 ** Math.min(attempt, 6));
|
|
665
|
+
const wait = Math.random() * ceiling;
|
|
666
|
+
console.error(`Retrying in ${(wait / 1000).toFixed(1)}s (attempt ${attempt})…`);
|
|
667
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, wait));
|
|
668
|
+
};
|
|
669
|
+
const ensureCompatible = async () => {
|
|
670
|
+
const current = await fetchGatewayVersion(config.gateway);
|
|
671
|
+
const error = compatibilityError(PACKAGE_VERSION, current);
|
|
672
|
+
if (error) {
|
|
673
|
+
console.error(error);
|
|
674
|
+
process.exit(1);
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
while (true) {
|
|
678
|
+
try {
|
|
679
|
+
if (Date.now() >= nextUpdateCheck) {
|
|
680
|
+
await ensureCompatible();
|
|
681
|
+
await updateIfNeeded(base, headers);
|
|
682
|
+
nextUpdateCheck = Date.now() + 5 * 60_000;
|
|
683
|
+
}
|
|
684
|
+
const response = await fetch(`${base}/tasks/next`, {
|
|
685
|
+
headers,
|
|
686
|
+
redirect: 'error',
|
|
687
|
+
signal: AbortSignal.timeout(30_000),
|
|
688
|
+
});
|
|
689
|
+
if (response.status === 502 || response.status === 503) {
|
|
690
|
+
throw new Error(`poll failed: HTTP ${response.status} (gateway temporarily unavailable)`);
|
|
691
|
+
}
|
|
692
|
+
if (!response.ok) throw new Error(`poll failed: HTTP ${response.status}`);
|
|
693
|
+
if (failures > 0) console.log('Reconnected to Jian Remote.');
|
|
694
|
+
failures = 0;
|
|
695
|
+
const { task } = await response.json();
|
|
696
|
+
if (!task) {
|
|
697
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 1000));
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
const result = await execute(task);
|
|
701
|
+
// Retry the result rather than rerunning the command after a transient network error.
|
|
702
|
+
await postResult(base, headers, result);
|
|
703
|
+
} catch (error) {
|
|
704
|
+
console.error(error instanceof Error ? error.message : error);
|
|
705
|
+
await delay(++failures);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const invokedAsCli =
|
|
711
|
+
Boolean(process.argv[1]) && resolve(fileURLToPath(import.meta.url)) === resolve(process.argv[1]);
|
|
712
|
+
|
|
713
|
+
if (invokedAsCli) {
|
|
714
|
+
try {
|
|
715
|
+
if (args[0] === 'install') await install();
|
|
716
|
+
else if (args[0] === 'update') await update();
|
|
717
|
+
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
|
+
);
|
|
722
|
+
} catch (error) {
|
|
723
|
+
console.error(error instanceof Error ? error.message : error);
|
|
724
|
+
process.exitCode = 1;
|
|
725
|
+
}
|
|
726
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jian-ai/remote-control",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Jian Remote Control worker — connect a computer to a Jian gateway over HTTPS",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"jian-remote": "./bin/jian-remote.mjs"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./bin/jian-remote.mjs",
|
|
11
|
+
"./bin/jian-remote.mjs": "./bin/jian-remote.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=24"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test tests/*.test.mjs"
|
|
25
|
+
},
|
|
26
|
+
"license": "Apache-2.0",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/lucasaarch/jian.git",
|
|
30
|
+
"directory": "apps/remote-control"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/lucasaarch/jian/blob/main/docs/remote.md",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/lucasaarch/jian/issues"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"jian",
|
|
38
|
+
"remote",
|
|
39
|
+
"agent",
|
|
40
|
+
"gateway"
|
|
41
|
+
]
|
|
42
|
+
}
|