ai-remote 0.4.4 → 0.4.5
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 +59 -20
- package/dist/cli.mjs +311 -130
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,53 +7,92 @@
|
|
|
7
7
|
|
|
8
8
|
## 🚀 Quick Start (CLI)
|
|
9
9
|
|
|
10
|
-
Run immediately with `npx` (zero install)
|
|
10
|
+
Run immediately with `npx` (zero install). `open` names the machine once and
|
|
11
|
+
leaves a session running; every command after it drives that session, so none of
|
|
12
|
+
them repeats the host, the account or the password:
|
|
11
13
|
|
|
12
14
|
```bash
|
|
13
15
|
# Connect and open a live supervision window:
|
|
14
16
|
AI_REMOTE_PASSWORD=secret npx ai-remote open 192.168.1.50 -u Administrator
|
|
15
17
|
|
|
16
18
|
# Take a screenshot:
|
|
17
|
-
|
|
19
|
+
npx ai-remote shot -o screen.png
|
|
18
20
|
|
|
19
21
|
# Click or type on the remote desktop:
|
|
20
|
-
npx ai-remote click
|
|
21
|
-
npx ai-remote type
|
|
22
|
-
npx ai-remote key
|
|
22
|
+
npx ai-remote click 500,300
|
|
23
|
+
npx ai-remote type "notepad"
|
|
24
|
+
npx ai-remote key Enter
|
|
23
25
|
|
|
24
26
|
# Run commands in the remote terminal over SSH:
|
|
25
|
-
npx ai-remote exec
|
|
27
|
+
npx ai-remote exec "dir"
|
|
28
|
+
|
|
29
|
+
# Let it go when you are done -- nothing closes on its own:
|
|
30
|
+
npx ai-remote close
|
|
26
31
|
```
|
|
27
32
|
|
|
28
33
|
### CLI Commands
|
|
29
34
|
|
|
30
35
|
```
|
|
31
|
-
ai-remote open
|
|
32
|
-
|
|
33
|
-
ai-remote
|
|
34
|
-
ai-remote
|
|
35
|
-
ai-remote
|
|
36
|
-
ai-remote
|
|
37
|
-
ai-remote
|
|
38
|
-
ai-remote
|
|
39
|
-
ai-remote
|
|
40
|
-
ai-remote view
|
|
41
|
-
ai-remote
|
|
42
|
-
ai-remote
|
|
36
|
+
ai-remote open <host[:port]> connect, show a window, and stay open
|
|
37
|
+
|
|
38
|
+
ai-remote shot screenshot the desktop
|
|
39
|
+
ai-remote click X,Y click, --button right|middle, --double
|
|
40
|
+
ai-remote type "text" type into whatever has focus
|
|
41
|
+
ai-remote key <Code> press keys, e.g. MetaLeft, ControlLeft+KeyA
|
|
42
|
+
ai-remote exec "cmd" run a command in the terminal (SSH)
|
|
43
|
+
ai-remote shell an interactive terminal
|
|
44
|
+
ai-remote do "steps" several actions in one go
|
|
45
|
+
ai-remote view open the window on the session
|
|
46
|
+
ai-remote view --close close the window, session keeps running
|
|
47
|
+
ai-remote status what the session is doing
|
|
48
|
+
ai-remote list every session that is open
|
|
49
|
+
ai-remote close close it; --all closes every session
|
|
50
|
+
ai-remote probe [<host[:port]>] is anything listening?
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Sessions
|
|
54
|
+
|
|
55
|
+
The session opened last is the one the commands drive, and it lives until it is
|
|
56
|
+
closed -- including across terminals, so an agent and the person watching it
|
|
57
|
+
reach the same desktop. With more than one open, `--session NAME` picks between
|
|
58
|
+
them; a session answers to its name, its host, or `host:port`.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npx ai-remote open 192.168.1.50 -u Administrator --session desk
|
|
62
|
+
npx ai-remote open 192.168.1.60 -u Administrator --session build
|
|
63
|
+
|
|
64
|
+
npx ai-remote list # * marks the one commands will use
|
|
65
|
+
npx ai-remote shot --session desk
|
|
66
|
+
npx ai-remote close --all
|
|
43
67
|
```
|
|
44
68
|
|
|
45
|
-
|
|
69
|
+
`probe` is the one command besides `open` that still takes an address, because
|
|
70
|
+
it answers whether a session could be opened at all; with no address it probes
|
|
71
|
+
the current session's host.
|
|
72
|
+
|
|
73
|
+
### Options for `open`
|
|
46
74
|
|
|
47
75
|
- `-u, --user NAME`: Account name on the host.
|
|
48
76
|
- `-d, --domain NAME`: Windows domain or computer name.
|
|
77
|
+
- `--session NAME`: Name this session (default: `host_port`).
|
|
49
78
|
- `--ssh-user NAME`: Account for the SSH terminal (defaults to `--user`).
|
|
50
79
|
- `--ssh-port N`: SSH port (default: 22).
|
|
51
80
|
- `-s, --security MODE`: `auto` | `nla` | `tls` | `rdp` (default: `auto`).
|
|
52
81
|
- `-W, --width N` / `-H, --height N`: Desktop resolution (default: 1280x800).
|
|
53
|
-
-
|
|
82
|
+
- `--idle MINUTES`: Close an unused session (default: 0, never).
|
|
54
83
|
- `--headless` / `--no-view`: Run headless without opening the viewer window.
|
|
84
|
+
- `--no-reuse`: A second session beside one that is already open.
|
|
85
|
+
|
|
86
|
+
### Options for the rest
|
|
87
|
+
|
|
88
|
+
- `--session NAME`: Which session to drive (default: the one opened last).
|
|
89
|
+
- `-o, --out FILE`: Screenshot output file path (default: `screen.png`).
|
|
90
|
+
- `--max-edge N`: Shrink a screenshot to fit N.
|
|
55
91
|
- `--json`: Output machine-readable JSON results on stdout.
|
|
56
92
|
|
|
93
|
+
The password comes from `AI_REMOTE_PASSWORD` (or `REMOTECTL_PASSWORD`). There is
|
|
94
|
+
no `--password` flag: a command line is visible to every process on the machine.
|
|
95
|
+
|
|
57
96
|
---
|
|
58
97
|
|
|
59
98
|
## 📦 Library Installation
|
package/dist/cli.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// src/cli/cli.ts
|
|
4
4
|
import { basename, resolve as resolvePath } from "node:path";
|
|
5
5
|
import { spawn as spawn2 } from "node:child_process";
|
|
6
|
-
import { readFileSync
|
|
6
|
+
import { readFileSync as readFileSync2, openSync } from "node:fs";
|
|
7
7
|
|
|
8
8
|
// src/protocols/ssh/messages.ts
|
|
9
9
|
var MSG = {
|
|
@@ -2827,8 +2827,8 @@ var Tls12Client = class {
|
|
|
2827
2827
|
case HANDSHAKE.FINISHED: {
|
|
2828
2828
|
const expected = await this.#verifyData("server finished");
|
|
2829
2829
|
const actual = body.subarray(0, 12);
|
|
2830
|
-
const
|
|
2831
|
-
if (!
|
|
2830
|
+
const matches2 = expected.length === actual.length && expected.every((byte, i) => byte === actual[i]);
|
|
2831
|
+
if (!matches2) throw new Error("TLS: the host Finished message did not verify");
|
|
2832
2832
|
this.transcript.push(message);
|
|
2833
2833
|
this.established = true;
|
|
2834
2834
|
this.resolveHandshake({
|
|
@@ -7569,6 +7569,7 @@ function sessionName(host, port, explicit) {
|
|
|
7569
7569
|
var socketPath = (name) => join(RUN_DIR, `${name}.sock`);
|
|
7570
7570
|
var metaPath = (name) => join(RUN_DIR, `${name}.json`);
|
|
7571
7571
|
var logPath = (name) => join(RUN_DIR, `${name}.log`);
|
|
7572
|
+
var currentPath = () => join(ROOT, "current");
|
|
7572
7573
|
|
|
7573
7574
|
// src/cli/daemon.ts
|
|
7574
7575
|
async function runDaemon(options) {
|
|
@@ -7606,7 +7607,11 @@ async function runDaemon(options) {
|
|
|
7606
7607
|
viewer: viewer?.url ?? null,
|
|
7607
7608
|
width: session.framebuffer.width || size.width,
|
|
7608
7609
|
height: session.framebuffer.height || size.height,
|
|
7609
|
-
startedAt
|
|
7610
|
+
startedAt,
|
|
7611
|
+
// So `shell` and `exec` need no flags either: the account the terminal
|
|
7612
|
+
// signs in as was settled when the session was opened.
|
|
7613
|
+
sshUser: options.sshUsername || bareUsername(options.username),
|
|
7614
|
+
sshPort: options.sshPort
|
|
7610
7615
|
}, null, 2));
|
|
7611
7616
|
}
|
|
7612
7617
|
let viewer = null;
|
|
@@ -7674,6 +7679,8 @@ async function runDaemon(options) {
|
|
|
7674
7679
|
window: viewer?.window?.kind ?? null,
|
|
7675
7680
|
viewers: viewer?.viewers ?? 0,
|
|
7676
7681
|
shell: shell ? "open" : shellStarting ? "opening" : "closed",
|
|
7682
|
+
sshUser: options.sshUsername || bareUsername(options.username),
|
|
7683
|
+
sshPort: options.sshPort,
|
|
7677
7684
|
pid: process.pid
|
|
7678
7685
|
};
|
|
7679
7686
|
case "shot": {
|
|
@@ -7797,10 +7804,89 @@ async function runScript(session, script) {
|
|
|
7797
7804
|
return done;
|
|
7798
7805
|
}
|
|
7799
7806
|
|
|
7807
|
+
// src/cli/sessions.ts
|
|
7808
|
+
import { existsSync as existsSync2, readFileSync, readdirSync, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
7809
|
+
function liveSessions() {
|
|
7810
|
+
ensureRunDir();
|
|
7811
|
+
return readdirSync(RUN_DIR).filter((file) => file.endsWith(".json")).flatMap((file) => {
|
|
7812
|
+
try {
|
|
7813
|
+
const meta = JSON.parse(readFileSync(`${RUN_DIR}/${file}`, "utf8"));
|
|
7814
|
+
try {
|
|
7815
|
+
process.kill(meta.pid, 0);
|
|
7816
|
+
} catch {
|
|
7817
|
+
cleanup(meta.name);
|
|
7818
|
+
return [];
|
|
7819
|
+
}
|
|
7820
|
+
return [meta];
|
|
7821
|
+
} catch {
|
|
7822
|
+
return [];
|
|
7823
|
+
}
|
|
7824
|
+
}).toSorted((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
7825
|
+
}
|
|
7826
|
+
function cleanup(name) {
|
|
7827
|
+
for (const file of [socketPath(name), metaPath(name), logPath(name)]) {
|
|
7828
|
+
try {
|
|
7829
|
+
if (existsSync2(file)) unlinkSync2(file);
|
|
7830
|
+
} catch {
|
|
7831
|
+
}
|
|
7832
|
+
}
|
|
7833
|
+
forgetCurrent(name);
|
|
7834
|
+
}
|
|
7835
|
+
function setCurrent(name) {
|
|
7836
|
+
ensureRunDir();
|
|
7837
|
+
writeFileSync2(currentPath(), `${name}
|
|
7838
|
+
`, { mode: 384 });
|
|
7839
|
+
}
|
|
7840
|
+
function readCurrent() {
|
|
7841
|
+
try {
|
|
7842
|
+
return readFileSync(currentPath(), "utf8").trim() || null;
|
|
7843
|
+
} catch {
|
|
7844
|
+
return null;
|
|
7845
|
+
}
|
|
7846
|
+
}
|
|
7847
|
+
function forgetCurrent(name) {
|
|
7848
|
+
if (name && readCurrent() !== name) return;
|
|
7849
|
+
try {
|
|
7850
|
+
if (existsSync2(currentPath())) unlinkSync2(currentPath());
|
|
7851
|
+
} catch {
|
|
7852
|
+
}
|
|
7853
|
+
}
|
|
7854
|
+
var matches = (meta, asked) => meta.name === asked || meta.host === asked || `${meta.host}:${meta.port}` === asked;
|
|
7855
|
+
function pickSession(open, asked, current, program = "ai-remote") {
|
|
7856
|
+
if (asked) {
|
|
7857
|
+
const named = open.find((meta) => matches(meta, asked));
|
|
7858
|
+
return named ? { session: named } : {
|
|
7859
|
+
problem: `No open session matches "${asked}".${listing(open, program)}`
|
|
7860
|
+
};
|
|
7861
|
+
}
|
|
7862
|
+
if (!open.length) {
|
|
7863
|
+
return { problem: `No session is open. Start one with \`${program} open <host[:port]>\`.` };
|
|
7864
|
+
}
|
|
7865
|
+
const marked = current ? open.find((meta) => meta.name === current) : void 0;
|
|
7866
|
+
if (marked) return { session: marked };
|
|
7867
|
+
if (open.length === 1) return { session: open[0] };
|
|
7868
|
+
return {
|
|
7869
|
+
problem: `${open.length} sessions are open and none of them is the current one. Pass \`--session NAME\`.${listing(open, program)}`
|
|
7870
|
+
};
|
|
7871
|
+
}
|
|
7872
|
+
function listing(open, program) {
|
|
7873
|
+
if (!open.length) return ` Nothing is open -- \`${program} open <host[:port]>\` starts a session.`;
|
|
7874
|
+
return `
|
|
7875
|
+
Open: ${open.map((meta) => `${meta.name} (${meta.host}:${meta.port})`).join(", ")}`;
|
|
7876
|
+
}
|
|
7877
|
+
function freeName(base, taken) {
|
|
7878
|
+
const used = new Set(taken);
|
|
7879
|
+
if (!used.has(base)) return base;
|
|
7880
|
+
for (let suffix = 2; ; suffix++) {
|
|
7881
|
+
const candidate = `${base}_${suffix}`;
|
|
7882
|
+
if (!used.has(candidate)) return candidate;
|
|
7883
|
+
}
|
|
7884
|
+
}
|
|
7885
|
+
|
|
7800
7886
|
// src/cli/cli.ts
|
|
7801
7887
|
function readLog(name) {
|
|
7802
7888
|
try {
|
|
7803
|
-
return
|
|
7889
|
+
return readFileSync2(logPath(name), "utf8").split("\n").filter((line) => line.trim() && !/^\s/.test(line) && !line.startsWith("[RDP] ")).slice(-4).join("\n");
|
|
7804
7890
|
} catch {
|
|
7805
7891
|
return "";
|
|
7806
7892
|
}
|
|
@@ -7811,40 +7897,56 @@ var NAME = (() => {
|
|
|
7811
7897
|
})();
|
|
7812
7898
|
var USAGE = `${NAME} -- drive a remote desktop, with a window you can watch
|
|
7813
7899
|
|
|
7814
|
-
|
|
7815
|
-
|
|
7816
|
-
${NAME}
|
|
7817
|
-
|
|
7818
|
-
|
|
7819
|
-
|
|
7820
|
-
${NAME}
|
|
7821
|
-
${NAME}
|
|
7822
|
-
${NAME}
|
|
7823
|
-
${NAME}
|
|
7824
|
-
${NAME}
|
|
7825
|
-
${NAME}
|
|
7900
|
+
One command names the machine:
|
|
7901
|
+
|
|
7902
|
+
${NAME} open <host[:port]> connect, show a window, and stay open
|
|
7903
|
+
|
|
7904
|
+
The rest drive that session, and take no host:
|
|
7905
|
+
|
|
7906
|
+
${NAME} shot screenshot the desktop
|
|
7907
|
+
${NAME} click X,Y click, --button right|middle, --double
|
|
7908
|
+
${NAME} type "text" type into whatever has focus
|
|
7909
|
+
${NAME} key <Code> press keys, e.g. MetaLeft, ControlLeft+KeyA
|
|
7910
|
+
${NAME} exec "cmd" run a command in the terminal (SSH)
|
|
7911
|
+
${NAME} shell an interactive terminal
|
|
7912
|
+
${NAME} do "steps" several actions in one go
|
|
7913
|
+
${NAME} view open the window on the session
|
|
7914
|
+
${NAME} view --close close the window, session keeps running
|
|
7915
|
+
${NAME} status what the session is doing
|
|
7916
|
+
${NAME} list every session that is open
|
|
7917
|
+
${NAME} close close it; --all closes every session
|
|
7918
|
+
${NAME} probe [<host[:port]>] is anything listening?
|
|
7826
7919
|
|
|
7827
7920
|
Steps for "do", separated by semicolons:
|
|
7828
7921
|
move X,Y \xB7 click X,Y[,right|middle] \xB7 dblclick X,Y \xB7 scroll X,Y,DX,DY
|
|
7829
7922
|
type TEXT \xB7 key Code[+Code...] \xB7 cad \xB7 wait MS \xB7 shot FILE
|
|
7830
7923
|
|
|
7831
|
-
|
|
7924
|
+
Sessions
|
|
7925
|
+
The session opened last is the one commands use. --session NAME picks
|
|
7926
|
+
another, and a session answers to its name, its host, or host:port.
|
|
7927
|
+
A session stays open until it is closed; \`${NAME} list\` shows them.
|
|
7928
|
+
|
|
7929
|
+
Options for open
|
|
7832
7930
|
-u, --user NAME account on the host
|
|
7833
7931
|
-d, --domain NAME Windows domain or computer name
|
|
7932
|
+
--session NAME name this session (default: host_port)
|
|
7834
7933
|
--ssh-user NAME only if the terminal signs in as someone else;
|
|
7835
7934
|
by default it reuses --user and the same password
|
|
7836
7935
|
--ssh-port N SSH port (default: 22)
|
|
7837
7936
|
-s, --security MODE auto | nla | tls | rdp (default: auto)
|
|
7838
7937
|
-W, --width N desktop width (default: 1280)
|
|
7839
7938
|
-H, --height N desktop height (default: 800)
|
|
7840
|
-
-o, --out FILE where a screenshot goes (default: screen.png)
|
|
7841
|
-
--max-edge N shrink a screenshot to fit N
|
|
7842
7939
|
--view-port N port for the window (default: 7373)
|
|
7843
7940
|
--idle MINUTES close an unused session (default: 0, never)
|
|
7844
7941
|
--headless no window (same as --no-view)
|
|
7845
7942
|
--no-view no window
|
|
7846
7943
|
--no-open window, but do not launch a browser
|
|
7847
|
-
--no-reuse a
|
|
7944
|
+
--no-reuse a second session beside one that is already open
|
|
7945
|
+
|
|
7946
|
+
Options for the rest
|
|
7947
|
+
--session NAME which session to drive (default: the last opened)
|
|
7948
|
+
-o, --out FILE where a screenshot goes (default: screen.png)
|
|
7949
|
+
--max-edge N shrink a screenshot to fit N
|
|
7848
7950
|
--json machine-readable result on stdout
|
|
7849
7951
|
|
|
7850
7952
|
The password comes from AI_REMOTE_PASSWORD (or REMOTECTL_PASSWORD). There is no
|
|
@@ -7871,6 +7973,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
|
7871
7973
|
"view-port",
|
|
7872
7974
|
"idle",
|
|
7873
7975
|
"name",
|
|
7976
|
+
"session",
|
|
7874
7977
|
"ssh-user",
|
|
7875
7978
|
"ssh-port",
|
|
7876
7979
|
"settle"
|
|
@@ -7888,7 +7991,7 @@ function parse(argv) {
|
|
|
7888
7991
|
if (VALUE_FLAGS.has(name)) flags[name] = argv[++i] ?? "";
|
|
7889
7992
|
else flags[name] = true;
|
|
7890
7993
|
}
|
|
7891
|
-
return { command: positional[0] ?? "",
|
|
7994
|
+
return { command: positional[0] ?? "", rest: positional.slice(1), flags };
|
|
7892
7995
|
}
|
|
7893
7996
|
var flag = (args, ...names) => {
|
|
7894
7997
|
for (const name of names) if (typeof args.flags[name] === "string") return args.flags[name];
|
|
@@ -7908,29 +8011,38 @@ var CliError = class extends Error {
|
|
|
7908
8011
|
code;
|
|
7909
8012
|
};
|
|
7910
8013
|
var password = () => process.env.AI_REMOTE_PASSWORD ?? process.env.REMOTECTL_PASSWORD ?? "";
|
|
7911
|
-
function
|
|
7912
|
-
|
|
7913
|
-
|
|
7914
|
-
|
|
7915
|
-
const meta = JSON.parse(readFileSync(`${RUN_DIR}/${file}`, "utf8"));
|
|
7916
|
-
try {
|
|
7917
|
-
process.kill(meta.pid, 0);
|
|
7918
|
-
} catch {
|
|
7919
|
-
cleanup(meta.name);
|
|
7920
|
-
return [];
|
|
7921
|
-
}
|
|
7922
|
-
return [meta];
|
|
7923
|
-
} catch {
|
|
7924
|
-
return [];
|
|
7925
|
-
}
|
|
7926
|
-
});
|
|
8014
|
+
function currentSession(args) {
|
|
8015
|
+
const chosen = pickSession(liveSessions(), flag(args, "session", "name"), readCurrent(), NAME);
|
|
8016
|
+
if ("problem" in chosen) throw new CliError(chosen.problem, 12);
|
|
8017
|
+
return chosen.session;
|
|
7927
8018
|
}
|
|
7928
|
-
function
|
|
7929
|
-
|
|
7930
|
-
|
|
7931
|
-
|
|
7932
|
-
}
|
|
7933
|
-
|
|
8019
|
+
function refuseTarget(command, token) {
|
|
8020
|
+
const address = /^(\d{1,3}\.){3}\d{1,3}(:\d+)?$/.test(token) || /^[\w-]+(\.[\w-]+)+(:\d+)?$/.test(token) || /^localhost(:\d+)?$/i.test(token);
|
|
8021
|
+
throw new CliError(
|
|
8022
|
+
address ? `\`${command}\` does not take a host: it drives the session \`${NAME} open\` left running.
|
|
8023
|
+
${NAME} open ${token} then ${NAME} ${command} ...
|
|
8024
|
+
--session NAME picks between several; \`${NAME} list\` shows what is open.` : `\`${command}\` takes no argument: it acts on the session \`${NAME} open\` left running.
|
|
8025
|
+
${NAME} ${command} --session ${token} acts on another one
|
|
8026
|
+
\`${NAME} list\` shows what is open.`,
|
|
8027
|
+
12
|
|
8028
|
+
);
|
|
8029
|
+
}
|
|
8030
|
+
var TAKES_NOTHING = /* @__PURE__ */ new Set(["shot", "view", "status", "shell", "close"]);
|
|
8031
|
+
var COORDINATE = /^-?\d+\s*,\s*-?\d+$/;
|
|
8032
|
+
function refuseLegacyTarget(args) {
|
|
8033
|
+
const { command, rest } = args;
|
|
8034
|
+
if (TAKES_NOTHING.has(command) && rest.length) refuseTarget(command, rest[0]);
|
|
8035
|
+
const spec = flag(args, "at") ?? rest[0];
|
|
8036
|
+
if (command === "click" && spec && !COORDINATE.test(spec) && rest[1] && COORDINATE.test(rest[1])) {
|
|
8037
|
+
refuseTarget("click", spec);
|
|
8038
|
+
}
|
|
8039
|
+
if (command === "key" && rest[0] && /[.:]/.test(rest[0])) refuseTarget("key", rest[0]);
|
|
8040
|
+
}
|
|
8041
|
+
function refuseSessionHost(args, command, session) {
|
|
8042
|
+
if (args.rest.length < 2) return;
|
|
8043
|
+
const token = args.rest[0].replace(/^\w+:\/\//, "");
|
|
8044
|
+
if (token === session.host || token === `${session.host}:${session.port}` || token === session.name) {
|
|
8045
|
+
refuseTarget(command, args.rest[0]);
|
|
7934
8046
|
}
|
|
7935
8047
|
}
|
|
7936
8048
|
async function ask(name, op, args = {}) {
|
|
@@ -7942,6 +8054,14 @@ async function ask(name, op, args = {}) {
|
|
|
7942
8054
|
if (!response.ok) throw new CliError(response.error ?? `The session refused "${op}".`, response.code ?? 1);
|
|
7943
8055
|
return response.result;
|
|
7944
8056
|
}
|
|
8057
|
+
async function tell(session, op, args = {}) {
|
|
8058
|
+
const result = await ask(session.name, op, args);
|
|
8059
|
+
if (result === null) {
|
|
8060
|
+
cleanup(session.name);
|
|
8061
|
+
throw new CliError(`The session ${session.name} is no longer answering. It has been cleaned up.`, 12);
|
|
8062
|
+
}
|
|
8063
|
+
return result;
|
|
8064
|
+
}
|
|
7945
8065
|
async function startSession(args, name, host, port) {
|
|
7946
8066
|
const daemonArgs = [
|
|
7947
8067
|
process.argv[1],
|
|
@@ -7965,8 +8085,10 @@ async function startSession(args, name, host, port) {
|
|
|
7965
8085
|
flag(args, "ssh-port") ?? "22",
|
|
7966
8086
|
"--view-port",
|
|
7967
8087
|
flag(args, "view-port") ?? "7373",
|
|
8088
|
+
// Zero unless asked for: a session stays open until it is closed. An agent
|
|
8089
|
+
// that pauses to think is not an agent that has finished.
|
|
7968
8090
|
"--idle",
|
|
7969
|
-
flag(args, "idle") ?? "
|
|
8091
|
+
flag(args, "idle") ?? "0"
|
|
7970
8092
|
];
|
|
7971
8093
|
if (has(args, "no-view", "headless")) daemonArgs.push("--no-view");
|
|
7972
8094
|
if (has(args, "no-open")) daemonArgs.push("--no-open");
|
|
@@ -8000,38 +8122,84 @@ async function main() {
|
|
|
8000
8122
|
return;
|
|
8001
8123
|
}
|
|
8002
8124
|
const json = has(args, "json");
|
|
8003
|
-
if (args.command === "
|
|
8125
|
+
if (args.command === "__session") {
|
|
8126
|
+
const { host, port } = splitTarget(args.rest[0] ?? "", 3389);
|
|
8127
|
+
await runDaemon({
|
|
8128
|
+
name: sessionName(host, port, flag(args, "name", "session")),
|
|
8129
|
+
host,
|
|
8130
|
+
port,
|
|
8131
|
+
username: flag(args, "u", "user") ?? "",
|
|
8132
|
+
password: password(),
|
|
8133
|
+
domain: flag(args, "d", "domain") ?? "",
|
|
8134
|
+
width: Number(flag(args, "W", "width") ?? 1280),
|
|
8135
|
+
height: Number(flag(args, "H", "height") ?? 800),
|
|
8136
|
+
security: flag(args, "s", "security") ?? "auto",
|
|
8137
|
+
clientName: "ai-remote",
|
|
8138
|
+
view: !has(args, "no-view", "headless"),
|
|
8139
|
+
launchBrowser: !has(args, "no-open"),
|
|
8140
|
+
viewPort: Number(flag(args, "view-port") ?? 7373),
|
|
8141
|
+
sshUsername: flag(args, "ssh-user") ?? flag(args, "u", "user") ?? "",
|
|
8142
|
+
sshPort: Number(flag(args, "ssh-port") ?? 22),
|
|
8143
|
+
tab: has(args, "tab"),
|
|
8144
|
+
fullscreen: has(args, "fullscreen"),
|
|
8145
|
+
idleMs: Math.max(0, Number(flag(args, "idle") ?? 0)) * 6e4
|
|
8146
|
+
});
|
|
8147
|
+
return;
|
|
8148
|
+
}
|
|
8149
|
+
if (args.command === "__window") {
|
|
8150
|
+
await runWindow(process.argv.slice(3));
|
|
8151
|
+
return;
|
|
8152
|
+
}
|
|
8153
|
+
if (args.command === "list") {
|
|
8004
8154
|
const sessions = liveSessions();
|
|
8155
|
+
const current = readCurrent();
|
|
8005
8156
|
if (json) {
|
|
8006
|
-
process.stdout.write(`${JSON.stringify({ sessions })}
|
|
8157
|
+
process.stdout.write(`${JSON.stringify({ current, sessions })}
|
|
8007
8158
|
`);
|
|
8008
8159
|
return;
|
|
8009
8160
|
}
|
|
8010
8161
|
if (!sessions.length) {
|
|
8011
|
-
process.stdout.write(
|
|
8162
|
+
process.stdout.write(`No sessions are open. \`${NAME} open <host>\` starts one.
|
|
8163
|
+
`);
|
|
8012
8164
|
return;
|
|
8013
8165
|
}
|
|
8014
8166
|
for (const meta of sessions) {
|
|
8015
|
-
process.stdout.write(`${meta.name} ${meta.host}:${meta.port} ${meta.width}x${meta.height} pid ${meta.pid}${meta.viewer ? ` ${meta.viewer}` : ""}
|
|
8167
|
+
process.stdout.write(`${meta.name === current ? "*" : " "} ${meta.name} ${meta.host}:${meta.port} ${meta.width}x${meta.height} pid ${meta.pid}${meta.viewer ? ` ${meta.viewer}` : ""}
|
|
8016
8168
|
`);
|
|
8017
8169
|
}
|
|
8018
8170
|
return;
|
|
8019
8171
|
}
|
|
8020
|
-
if (
|
|
8021
|
-
|
|
8022
|
-
|
|
8023
|
-
|
|
8024
|
-
const
|
|
8025
|
-
|
|
8026
|
-
|
|
8027
|
-
|
|
8028
|
-
|
|
8029
|
-
|
|
8030
|
-
|
|
8031
|
-
|
|
8172
|
+
if (args.command === "open") {
|
|
8173
|
+
const target = args.rest[0];
|
|
8174
|
+
if (!target) throw new CliError(`Name the machine to connect to: \`${NAME} open <host[:port]>\``, 12);
|
|
8175
|
+
const { host, port } = splitTarget(target, 3389);
|
|
8176
|
+
const asked = flag(args, "session", "name");
|
|
8177
|
+
const base = sessionName(host, port, asked);
|
|
8178
|
+
const name2 = has(args, "no-reuse") && !asked ? freeName(base, liveSessions().map((meta) => meta.name)) : base;
|
|
8179
|
+
let status = has(args, "no-reuse") ? null : await ask(name2, "status");
|
|
8180
|
+
const reused = Boolean(status);
|
|
8181
|
+
if (!status) status = await startSession(args, name2, host, port);
|
|
8182
|
+
if (!has(args, "no-view", "headless") && !status.viewer) {
|
|
8183
|
+
Object.assign(status, await ask(name2, "view-open", { launch: !has(args, "no-open") }));
|
|
8184
|
+
}
|
|
8185
|
+
setCurrent(name2);
|
|
8186
|
+
report(json, "open", {
|
|
8187
|
+
host,
|
|
8188
|
+
port,
|
|
8189
|
+
name: name2,
|
|
8190
|
+
reused,
|
|
8191
|
+
width: status.width,
|
|
8192
|
+
height: status.height,
|
|
8193
|
+
viewer: status.viewer,
|
|
8194
|
+
window: status.window,
|
|
8195
|
+
viewers: status.viewers
|
|
8196
|
+
});
|
|
8032
8197
|
return;
|
|
8033
8198
|
}
|
|
8034
8199
|
if (args.command === "probe") {
|
|
8200
|
+
const target = args.rest[0];
|
|
8201
|
+
const where = target ? splitTarget(target, 3389) : currentSession(args);
|
|
8202
|
+
const { host, port } = where;
|
|
8035
8203
|
const { reachable, ms, detail } = await probe(host, port);
|
|
8036
8204
|
if (json) process.stdout.write(`${JSON.stringify({ host, port, reachable, ms, detail })}
|
|
8037
8205
|
`);
|
|
@@ -8040,106 +8208,103 @@ async function main() {
|
|
|
8040
8208
|
if (!reachable) throw new CliError(detail, 10);
|
|
8041
8209
|
return;
|
|
8042
8210
|
}
|
|
8043
|
-
|
|
8044
|
-
|
|
8045
|
-
|
|
8046
|
-
|
|
8047
|
-
|
|
8048
|
-
|
|
8049
|
-
|
|
8050
|
-
|
|
8051
|
-
|
|
8052
|
-
|
|
8053
|
-
|
|
8054
|
-
|
|
8055
|
-
view: !has(args, "no-view", "headless"),
|
|
8056
|
-
launchBrowser: !has(args, "no-open"),
|
|
8057
|
-
viewPort: Number(flag(args, "view-port") ?? 7373),
|
|
8058
|
-
sshUsername: flag(args, "ssh-user") ?? flag(args, "u", "user") ?? "",
|
|
8059
|
-
sshPort: Number(flag(args, "ssh-port") ?? 22),
|
|
8060
|
-
tab: has(args, "tab"),
|
|
8061
|
-
fullscreen: has(args, "fullscreen"),
|
|
8062
|
-
// Zero by default: a session stays open until it is closed. An agent
|
|
8063
|
-
// that pauses to think is not an agent that has finished.
|
|
8064
|
-
idleMs: Math.max(0, Number(flag(args, "idle") ?? 0)) * 6e4
|
|
8065
|
-
});
|
|
8211
|
+
refuseLegacyTarget(args);
|
|
8212
|
+
if (args.command === "close") {
|
|
8213
|
+
const targets = has(args, "all") ? liveSessions().map((meta) => meta.name) : [currentSession(args).name];
|
|
8214
|
+
for (const target of targets) {
|
|
8215
|
+
await ask(target, "close").catch(() => null);
|
|
8216
|
+
cleanup(target);
|
|
8217
|
+
process.stdout.write(`Closed ${target}.
|
|
8218
|
+
`);
|
|
8219
|
+
}
|
|
8220
|
+
if (!targets.length) process.stdout.write("No sessions are open.\n");
|
|
8221
|
+
const left = liveSessions();
|
|
8222
|
+
if (!readCurrent() && left.length) setCurrent(left[left.length - 1].name);
|
|
8066
8223
|
return;
|
|
8067
8224
|
}
|
|
8068
|
-
if (args.command === "
|
|
8069
|
-
|
|
8225
|
+
if (args.command === "status" && !liveSessions().length) {
|
|
8226
|
+
if (json) process.stdout.write(`${JSON.stringify({ ok: true, sessions: [] })}
|
|
8227
|
+
`);
|
|
8228
|
+
else process.stdout.write("No sessions are open.\n");
|
|
8070
8229
|
return;
|
|
8071
8230
|
}
|
|
8231
|
+
const session = currentSession(args);
|
|
8232
|
+
const { name } = session;
|
|
8072
8233
|
if (args.command === "shell") {
|
|
8073
|
-
await interactiveShell(args,
|
|
8234
|
+
await interactiveShell(args, session);
|
|
8074
8235
|
return;
|
|
8075
8236
|
}
|
|
8076
|
-
|
|
8077
|
-
|
|
8078
|
-
|
|
8079
|
-
|
|
8237
|
+
const summary = {
|
|
8238
|
+
host: session.host,
|
|
8239
|
+
port: session.port,
|
|
8240
|
+
name,
|
|
8241
|
+
width: session.width,
|
|
8242
|
+
height: session.height
|
|
8243
|
+
};
|
|
8080
8244
|
switch (args.command) {
|
|
8081
|
-
case "
|
|
8082
|
-
|
|
8083
|
-
Object.assign(status, await ask(name, "view-open", { launch: !has(args, "no-open") }));
|
|
8084
|
-
}
|
|
8085
|
-
Object.assign(report, { viewer: status.viewer, window: status.window, viewers: status.viewers });
|
|
8245
|
+
case "status": {
|
|
8246
|
+
Object.assign(summary, await tell(session, "status"));
|
|
8086
8247
|
break;
|
|
8248
|
+
}
|
|
8087
8249
|
case "view": {
|
|
8088
8250
|
if (has(args, "close")) {
|
|
8089
|
-
const { closed } = await
|
|
8090
|
-
Object.assign(
|
|
8251
|
+
const { closed } = await tell(session, "view-close");
|
|
8252
|
+
Object.assign(summary, { viewer: null, closed });
|
|
8091
8253
|
break;
|
|
8092
8254
|
}
|
|
8093
|
-
Object.assign(
|
|
8255
|
+
Object.assign(summary, await tell(session, "view-open", { launch: !has(args, "no-open") }));
|
|
8094
8256
|
break;
|
|
8095
8257
|
}
|
|
8096
8258
|
case "shot": {
|
|
8097
8259
|
const file = resolvePath(flag(args, "o", "out") ?? "screen.png");
|
|
8098
8260
|
const maxEdge = Number(flag(args, "max-edge") ?? 0);
|
|
8099
|
-
Object.assign(
|
|
8261
|
+
Object.assign(summary, await tell(session, "shot", { file, maxEdge }));
|
|
8100
8262
|
break;
|
|
8101
8263
|
}
|
|
8102
8264
|
case "click": {
|
|
8103
8265
|
const spec = flag(args, "at") ?? args.rest[0];
|
|
8104
|
-
if (!spec) throw new CliError(
|
|
8266
|
+
if (!spec) throw new CliError(`click needs a position, e.g. \`${NAME} click 640,400\``, 12);
|
|
8105
8267
|
const [x, y] = spec.split(",").map(Number);
|
|
8106
8268
|
const button2 = flag(args, "button") ?? "left";
|
|
8107
8269
|
const step = `click ${x},${y}${button2 === "left" ? "" : `,${button2}`}`;
|
|
8108
|
-
await
|
|
8109
|
-
Object.assign(
|
|
8270
|
+
await tell(session, "script", { script: has(args, "double") ? `${step}; ${step}` : step });
|
|
8271
|
+
Object.assign(summary, { clicked: { x, y, button: button2 } });
|
|
8110
8272
|
break;
|
|
8111
8273
|
}
|
|
8112
8274
|
case "type": {
|
|
8275
|
+
refuseSessionHost(args, "type", session);
|
|
8113
8276
|
const text = args.rest.join(" ");
|
|
8114
8277
|
if (!text) throw new CliError("type needs some text", 12);
|
|
8115
|
-
await
|
|
8116
|
-
Object.assign(
|
|
8278
|
+
await tell(session, "script", { script: `type ${text}` });
|
|
8279
|
+
Object.assign(summary, { typed: text.length });
|
|
8117
8280
|
break;
|
|
8118
8281
|
}
|
|
8119
8282
|
case "key": {
|
|
8120
8283
|
if (!args.rest.length) throw new CliError("key needs at least one code, e.g. MetaLeft", 12);
|
|
8121
|
-
await
|
|
8122
|
-
Object.assign(
|
|
8284
|
+
await tell(session, "script", { script: args.rest.map((code) => `key ${code}`).join("; ") });
|
|
8285
|
+
Object.assign(summary, { keys: args.rest });
|
|
8123
8286
|
break;
|
|
8124
8287
|
}
|
|
8125
8288
|
case "do": {
|
|
8289
|
+
refuseSessionHost(args, "do", session);
|
|
8126
8290
|
const script = args.rest.join(" ");
|
|
8127
8291
|
if (!script) throw new CliError('do needs a script, e.g. "key MetaLeft; wait 800; shot s.png"', 12);
|
|
8128
|
-
Object.assign(
|
|
8292
|
+
Object.assign(summary, await tell(session, "script", { script }));
|
|
8129
8293
|
break;
|
|
8130
8294
|
}
|
|
8131
8295
|
case "exec": {
|
|
8296
|
+
refuseSessionHost(args, "exec", session);
|
|
8132
8297
|
const command = args.rest.join(" ");
|
|
8133
8298
|
if (!command) throw new CliError("exec needs a command to run", 12);
|
|
8134
|
-
const result = await
|
|
8135
|
-
Object.assign(
|
|
8299
|
+
const result = await tell(session, "exec", { command });
|
|
8300
|
+
Object.assign(summary, result);
|
|
8136
8301
|
if (!json) {
|
|
8137
8302
|
process.stdout.write(result.output.endsWith("\n") ? result.output : `${result.output}
|
|
8138
8303
|
`);
|
|
8139
8304
|
if (result.exitStatus) process.stderr.write(`exit ${result.exitStatus}
|
|
8140
8305
|
`);
|
|
8141
8306
|
}
|
|
8142
|
-
if (json) process.stdout.write(`${JSON.stringify({ ok: result.exitStatus === 0, ...
|
|
8307
|
+
if (json) process.stdout.write(`${JSON.stringify({ ok: result.exitStatus === 0, ...summary })}
|
|
8143
8308
|
`);
|
|
8144
8309
|
process.exit(result.exitStatus === 0 ? 0 : 1);
|
|
8145
8310
|
return;
|
|
@@ -8147,16 +8312,29 @@ async function main() {
|
|
|
8147
8312
|
default:
|
|
8148
8313
|
throw new CliError(`Unknown command "${args.command}".`, 12);
|
|
8149
8314
|
}
|
|
8150
|
-
|
|
8315
|
+
report(json, args.command, summary);
|
|
8316
|
+
}
|
|
8317
|
+
function report(json, command, summary) {
|
|
8318
|
+
if (json) process.stdout.write(`${JSON.stringify({ ok: true, ...summary })}
|
|
8151
8319
|
`);
|
|
8152
|
-
else process.stdout.write(`${describe(
|
|
8320
|
+
else process.stdout.write(`${describe(command, summary)}
|
|
8153
8321
|
`);
|
|
8154
8322
|
}
|
|
8155
|
-
async function interactiveShell(args,
|
|
8323
|
+
async function interactiveShell(args, session) {
|
|
8324
|
+
const username = bareUsername(flag(args, "ssh-user") ?? session.sshUser ?? "");
|
|
8325
|
+
if (!username) {
|
|
8326
|
+
throw new CliError(
|
|
8327
|
+
`The session ${session.name} has no account for a terminal. Open it with -u, or pass --ssh-user here.`,
|
|
8328
|
+
12
|
|
8329
|
+
);
|
|
8330
|
+
}
|
|
8331
|
+
if (!password()) {
|
|
8332
|
+
throw new CliError("The terminal signs in for itself, so it needs AI_REMOTE_PASSWORD in this shell.", 11);
|
|
8333
|
+
}
|
|
8156
8334
|
const shell = new Shell({
|
|
8157
|
-
host,
|
|
8158
|
-
port: Number(flag(args, "ssh-port") ?? 22),
|
|
8159
|
-
username
|
|
8335
|
+
host: session.host,
|
|
8336
|
+
port: Number(flag(args, "ssh-port") ?? session.sshPort ?? 22),
|
|
8337
|
+
username,
|
|
8160
8338
|
password: password(),
|
|
8161
8339
|
columns: process.stdout.columns ?? 120,
|
|
8162
8340
|
rows: process.stdout.rows ?? 30
|
|
@@ -8194,26 +8372,29 @@ async function probe(host, port) {
|
|
|
8194
8372
|
socket.on("error", (error) => done(false, error.message));
|
|
8195
8373
|
});
|
|
8196
8374
|
}
|
|
8197
|
-
function describe(command,
|
|
8198
|
-
const size = `${
|
|
8199
|
-
const where = report.reused ? "the open session" : "a new session";
|
|
8375
|
+
function describe(command, summary) {
|
|
8376
|
+
const size = `${summary.width}x${summary.height}`;
|
|
8200
8377
|
if (command === "open") {
|
|
8201
|
-
const window =
|
|
8202
|
-
return `${
|
|
8203
|
-
Run more commands
|
|
8378
|
+
const window = summary.window === "window" ? "A window is open on it." : summary.window === "tab" ? "It opened in your browser." : summary.viewer ? `Open it at ${summary.viewer}` : "No window (headless).";
|
|
8379
|
+
return `${summary.reused ? "Already connected" : "Connected"} to ${summary.host}:${summary.port} at ${size}. ${window}
|
|
8380
|
+
Session ${summary.name}. Run more commands with no host, then \`${NAME} close\` when you are done.`;
|
|
8381
|
+
}
|
|
8382
|
+
if (command === "status") {
|
|
8383
|
+
return `${summary.name} ${summary.host}:${summary.port} ${size} ${summary.connected ? "connected" : "disconnected"} terminal ${summary.shell}
|
|
8384
|
+
${summary.viewer ? `Window: ${summary.viewer} (${summary.viewers} watching)` : "No window."}`;
|
|
8204
8385
|
}
|
|
8205
8386
|
if (command === "view") {
|
|
8206
|
-
if (
|
|
8207
|
-
return
|
|
8387
|
+
if (summary.viewer) return `Window: ${summary.viewer}`;
|
|
8388
|
+
return summary.closed ? "Closed the window. The session is still open." : "There was no window open.";
|
|
8208
8389
|
}
|
|
8209
|
-
if (command === "shot") return `Wrote ${
|
|
8390
|
+
if (command === "shot") return `Wrote ${summary.file} (${summary.width}x${summary.height}) from ${summary.name}.`;
|
|
8210
8391
|
if (command === "click") {
|
|
8211
|
-
const { x, y } =
|
|
8392
|
+
const { x, y } = summary.clicked;
|
|
8212
8393
|
return `Clicked (${x}, ${y}) on a ${size} desktop.`;
|
|
8213
8394
|
}
|
|
8214
|
-
if (command === "type") return `Typed ${
|
|
8215
|
-
if (command === "key") return `Pressed ${
|
|
8216
|
-
if (command === "do") return `Ran ${
|
|
8395
|
+
if (command === "type") return `Typed ${summary.typed} characters.`;
|
|
8396
|
+
if (command === "key") return `Pressed ${summary.keys.join(", ")}.`;
|
|
8397
|
+
if (command === "do") return `Ran ${summary.steps.length} steps: ${summary.steps.join(" \u2192 ")}`;
|
|
8217
8398
|
return "Done.";
|
|
8218
8399
|
}
|
|
8219
8400
|
main().catch((error) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-remote",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "RDP, VNC and SSH protocol engines that run in Node, a Worker or a browser -- plus `npx ai-remote`, a CLI that drives a machine and shows you what it is doing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|