@rome-os/node 0.1.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/LICENSE +21 -0
- package/README.md +58 -0
- package/bin/rome-node.js +2 -0
- package/dist/actions.d.ts +20 -0
- package/dist/actions.js +18 -0
- package/dist/client.d.ts +29 -0
- package/dist/client.js +143 -0
- package/dist/cloud.d.ts +7 -0
- package/dist/cloud.js +53 -0
- package/dist/connect.d.ts +2 -0
- package/dist/connect.js +114 -0
- package/dist/connector.d.ts +24 -0
- package/dist/connector.js +148 -0
- package/dist/daemon.d.ts +11 -0
- package/dist/daemon.js +208 -0
- package/dist/entry.d.ts +1 -0
- package/dist/entry.js +127 -0
- package/dist/executor.d.ts +5 -0
- package/dist/executor.js +121 -0
- package/dist/help.d.ts +1 -0
- package/dist/help.js +156 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/local.d.ts +9 -0
- package/dist/local.js +33 -0
- package/dist/login.d.ts +6 -0
- package/dist/login.js +100 -0
- package/dist/protocol.d.ts +30 -0
- package/dist/protocol.js +40 -0
- package/dist/socket.d.ts +2 -0
- package/dist/socket.js +27 -0
- package/dist/storage.d.ts +2 -0
- package/dist/storage.js +37 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rome OS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Rome Node
|
|
2
|
+
|
|
3
|
+
Rome Node exposes a computer to Rome instances in the same Cloud account while
|
|
4
|
+
`rome-node connect` runs. Programs run as the OS user who starts the command.
|
|
5
|
+
The computer needs Node.js 24 or later. It does not need a Rome instance.
|
|
6
|
+
|
|
7
|
+
Build an installation archive from this repository:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm --filter @rome-os/node build
|
|
11
|
+
pnpm --filter @rome-os/node pack --pack-destination /tmp
|
|
12
|
+
npm install -g /tmp/rome-os-node-0.1.0.tgz
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
On the target computer:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
rome-node connect --name "My computer"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Open the printed authorization URL in a browser on that computer and approve it.
|
|
22
|
+
The browser callback uses a temporary loopback listener. A remote browser needs
|
|
23
|
+
a tunnel to that listener. Ctrl+C disconnects and preserves the credential.
|
|
24
|
+
|
|
25
|
+
On the caller, configure a previously issued communication token through stdin:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
rome-node auth --cloud https://romeos.cc < /path/to/private-token-file
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This saves the token privately. The caller does not need Rome Core.
|
|
32
|
+
Run commands from an agent, script, or terminal:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
rome-node help
|
|
36
|
+
rome-node help device run
|
|
37
|
+
rome-node device
|
|
38
|
+
rome-node device describe <device-id>
|
|
39
|
+
rome-node device run <device-id> exec --args '{"command":"git","args":["status"],"cwd":"/workspace"}'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Use `rome-node <command> --help` or `-h` for command-specific help.
|
|
43
|
+
Help includes action arguments, examples, output fields, and failure handling.
|
|
44
|
+
It works offline without credentials and does not start the daemon.
|
|
45
|
+
|
|
46
|
+
The first device command starts a background CLI daemon. Commands share its
|
|
47
|
+
Gateway connection. `rome-node daemon status` reports its PID, and
|
|
48
|
+
`rome-node daemon stop` closes it. A later command starts it again. Stop the
|
|
49
|
+
daemon before changing credentials. `rome-node daemon serve` runs in the
|
|
50
|
+
foreground for diagnosis. Set `ROME_NODE_CONFIG_DIR` to isolate configurations.
|
|
51
|
+
|
|
52
|
+
Commands print JSON to stdout and diagnostics to stderr. Errors and nonzero
|
|
53
|
+
remote exit codes produce a nonzero local exit status. An authorized device is
|
|
54
|
+
not necessarily online. Use `describe` to query its current platform and actions.
|
|
55
|
+
|
|
56
|
+
The package exports the Gateway client, socket adapter, and action response
|
|
57
|
+
contract for other clients. See [the device architecture and operating guide](../../docs/rome-node.md)
|
|
58
|
+
for credential boundaries, file operations, output limits, and disconnection behavior.
|
package/bin/rome-node.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface ActionRequest {
|
|
2
|
+
type: "request";
|
|
3
|
+
action: string;
|
|
4
|
+
args: unknown;
|
|
5
|
+
}
|
|
6
|
+
export type ActionResponse = {
|
|
7
|
+
type: "response";
|
|
8
|
+
ok: true;
|
|
9
|
+
result: unknown;
|
|
10
|
+
} | {
|
|
11
|
+
type: "response";
|
|
12
|
+
ok: false;
|
|
13
|
+
error: {
|
|
14
|
+
code: string;
|
|
15
|
+
message: string;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
export declare function actionError(code: string, message: string): ActionResponse;
|
|
19
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
20
|
+
export declare function parseResponse(value: unknown): ActionResponse | null;
|
package/dist/actions.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function actionError(code, message) {
|
|
2
|
+
return { type: "response", ok: false, error: { code, message } };
|
|
3
|
+
}
|
|
4
|
+
export function isRecord(value) {
|
|
5
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
export function parseResponse(value) {
|
|
8
|
+
if (!isRecord(value) || value.type !== "response")
|
|
9
|
+
return null;
|
|
10
|
+
if (value.ok === true && Object.hasOwn(value, "result"))
|
|
11
|
+
return { type: "response", ok: true, result: value.result };
|
|
12
|
+
if (value.ok === false &&
|
|
13
|
+
isRecord(value.error) &&
|
|
14
|
+
typeof value.error.code === "string" &&
|
|
15
|
+
typeof value.error.message === "string")
|
|
16
|
+
return actionError(value.error.code, value.error.message);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type GatewayMessage, type OutboundEnvelope } from "./protocol.js";
|
|
2
|
+
export interface ClientSocket {
|
|
3
|
+
readyState: number;
|
|
4
|
+
bufferedAmount: number;
|
|
5
|
+
send(data: string): void;
|
|
6
|
+
close(code?: number, reason?: string): void;
|
|
7
|
+
addEventListener(type: "open", listener: () => void): void;
|
|
8
|
+
addEventListener(type: "message", listener: (event: {
|
|
9
|
+
data: unknown;
|
|
10
|
+
}) => void): void;
|
|
11
|
+
addEventListener(type: "close", listener: (event: {
|
|
12
|
+
code: number;
|
|
13
|
+
}) => void): void;
|
|
14
|
+
addEventListener(type: "error", listener: () => void): void;
|
|
15
|
+
}
|
|
16
|
+
export type ConnectionStatus = "connecting" | "online" | "retrying" | "revoked" | "superseded" | "stopped";
|
|
17
|
+
export interface GatewayClientOptions {
|
|
18
|
+
gatewayUrl: string;
|
|
19
|
+
deviceToken: string;
|
|
20
|
+
createSocket(url: string, authorization: string): ClientSocket;
|
|
21
|
+
onMessage(message: GatewayMessage): void;
|
|
22
|
+
onStatus?(status: ConnectionStatus): void;
|
|
23
|
+
random?(): number;
|
|
24
|
+
beforeConnect?(): Promise<string | null>;
|
|
25
|
+
}
|
|
26
|
+
export declare function connectGateway(options: GatewayClientOptions): {
|
|
27
|
+
send(envelope: OutboundEnvelope): boolean;
|
|
28
|
+
stop(): void;
|
|
29
|
+
};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { byteLength, CLOSE, gatewayMessage, MAX_CLIENT_BUFFER_BYTES, MAX_MESSAGE_BYTES, outboundEnvelope, } from "./protocol.js";
|
|
2
|
+
export function connectGateway(options) {
|
|
3
|
+
let url = new URL(options.gatewayUrl);
|
|
4
|
+
if (url.protocol !== "wss:" || url.username || url.password || url.search || url.hash)
|
|
5
|
+
throw new Error("A credential-free WSS URL is required");
|
|
6
|
+
if (!/^(?:romedev_|romemob_)[A-Za-z0-9_-]{43}$/.test(options.deviceToken))
|
|
7
|
+
throw new Error("Invalid device token format");
|
|
8
|
+
let stopped = false;
|
|
9
|
+
let socket = null;
|
|
10
|
+
let attempt = 0;
|
|
11
|
+
let timer;
|
|
12
|
+
let connectingTimer;
|
|
13
|
+
let stableTimer;
|
|
14
|
+
const status = (value) => options.onStatus?.(value);
|
|
15
|
+
const schedule = () => {
|
|
16
|
+
if (stopped || timer)
|
|
17
|
+
return;
|
|
18
|
+
status("retrying");
|
|
19
|
+
const delay = Math.min(60_000, 1000 * 2 ** Math.min(attempt++, 6));
|
|
20
|
+
timer = setTimeout(() => {
|
|
21
|
+
timer = undefined;
|
|
22
|
+
void open();
|
|
23
|
+
}, delay * (0.75 + (options.random ?? Math.random)() * 0.5));
|
|
24
|
+
};
|
|
25
|
+
const open = async () => {
|
|
26
|
+
if (stopped)
|
|
27
|
+
return;
|
|
28
|
+
status("connecting");
|
|
29
|
+
let current;
|
|
30
|
+
try {
|
|
31
|
+
if (options.beforeConnect) {
|
|
32
|
+
const next = await options.beforeConnect();
|
|
33
|
+
if (stopped)
|
|
34
|
+
return;
|
|
35
|
+
if (next === null) {
|
|
36
|
+
stopped = true;
|
|
37
|
+
status("revoked");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
url = new URL(next);
|
|
41
|
+
if (url.protocol !== "wss:" || url.username || url.password || url.search || url.hash)
|
|
42
|
+
throw new Error("A credential-free WSS URL is required");
|
|
43
|
+
}
|
|
44
|
+
current = options.createSocket(url.toString(), `Bearer ${options.deviceToken}`);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
schedule();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
socket = current;
|
|
51
|
+
let finished = false;
|
|
52
|
+
const finish = (code) => {
|
|
53
|
+
if (finished || socket !== current)
|
|
54
|
+
return;
|
|
55
|
+
finished = true;
|
|
56
|
+
clearTimeout(connectingTimer);
|
|
57
|
+
clearTimeout(stableTimer);
|
|
58
|
+
socket = null;
|
|
59
|
+
if (code === CLOSE.revoked || code === CLOSE.superseded) {
|
|
60
|
+
stopped = true;
|
|
61
|
+
status(code === CLOSE.revoked ? "revoked" : "superseded");
|
|
62
|
+
}
|
|
63
|
+
else
|
|
64
|
+
schedule();
|
|
65
|
+
};
|
|
66
|
+
connectingTimer = setTimeout(() => {
|
|
67
|
+
finish();
|
|
68
|
+
current.close();
|
|
69
|
+
}, 10_000);
|
|
70
|
+
current.addEventListener("open", () => {
|
|
71
|
+
if (stopped || socket !== current) {
|
|
72
|
+
current.close();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
clearTimeout(connectingTimer);
|
|
76
|
+
stableTimer = setTimeout(() => {
|
|
77
|
+
attempt = 0;
|
|
78
|
+
}, 30_000);
|
|
79
|
+
status("online");
|
|
80
|
+
});
|
|
81
|
+
current.addEventListener("message", ({ data }) => {
|
|
82
|
+
if (stopped ||
|
|
83
|
+
socket !== current ||
|
|
84
|
+
typeof data !== "string" ||
|
|
85
|
+
byteLength(data) > MAX_MESSAGE_BYTES)
|
|
86
|
+
return;
|
|
87
|
+
let message;
|
|
88
|
+
try {
|
|
89
|
+
message = gatewayMessage(JSON.parse(data));
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (message)
|
|
95
|
+
options.onMessage(message);
|
|
96
|
+
});
|
|
97
|
+
current.addEventListener("close", ({ code }) => finish(code));
|
|
98
|
+
current.addEventListener("error", () => {
|
|
99
|
+
finish();
|
|
100
|
+
current.close();
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
void open();
|
|
104
|
+
return {
|
|
105
|
+
send(envelope) {
|
|
106
|
+
if (stopped || !socket || socket.readyState !== 1 || !outboundEnvelope(envelope))
|
|
107
|
+
return false;
|
|
108
|
+
let text;
|
|
109
|
+
try {
|
|
110
|
+
text = JSON.stringify(envelope);
|
|
111
|
+
if (!outboundEnvelope(JSON.parse(text)))
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const size = byteLength(text);
|
|
118
|
+
if (size > MAX_MESSAGE_BYTES)
|
|
119
|
+
return false;
|
|
120
|
+
if (socket.bufferedAmount + size > MAX_CLIENT_BUFFER_BYTES) {
|
|
121
|
+
socket.close(CLOSE.outputLimit, "outbound buffer limit");
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
socket.send(text);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
stop() {
|
|
133
|
+
stopped = true;
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
clearTimeout(connectingTimer);
|
|
136
|
+
clearTimeout(stableTimer);
|
|
137
|
+
const previous = socket;
|
|
138
|
+
socket = null;
|
|
139
|
+
previous?.close(1000, "client stopped");
|
|
140
|
+
status("stopped");
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
package/dist/cloud.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare class CloudError extends Error {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
constructor(code: string);
|
|
4
|
+
}
|
|
5
|
+
export declare function cloudOrigin(value: string): string;
|
|
6
|
+
export declare function cloudRequest(origin: string, path: string, token: string, method?: string): Promise<unknown>;
|
|
7
|
+
export declare function gatewayConfig(origin: string, token: string): Promise<string>;
|
package/dist/cloud.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { isRecord } from "./actions.js";
|
|
2
|
+
export class CloudError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code) {
|
|
5
|
+
super(code === "invalid_device_session"
|
|
6
|
+
? "Device authorization is invalid."
|
|
7
|
+
: "Rome Cloud is unavailable. Retry later.");
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function cloudOrigin(value) {
|
|
12
|
+
const url = new URL(value);
|
|
13
|
+
if (url.username ||
|
|
14
|
+
url.password ||
|
|
15
|
+
url.search ||
|
|
16
|
+
url.hash ||
|
|
17
|
+
url.pathname !== "/" ||
|
|
18
|
+
(url.protocol !== "https:" &&
|
|
19
|
+
!(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))))
|
|
20
|
+
throw new Error("Use an HTTPS Cloud origin. HTTP is allowed only on loopback.");
|
|
21
|
+
return url.origin;
|
|
22
|
+
}
|
|
23
|
+
export async function cloudRequest(origin, path, token, method = "GET") {
|
|
24
|
+
let response;
|
|
25
|
+
try {
|
|
26
|
+
response = await fetch(new URL(path, cloudOrigin(origin)), {
|
|
27
|
+
method,
|
|
28
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
29
|
+
redirect: "error",
|
|
30
|
+
signal: AbortSignal.timeout(10_000),
|
|
31
|
+
cache: "no-store",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new CloudError("cloud_unavailable");
|
|
36
|
+
}
|
|
37
|
+
const body = await response.json().catch(() => null);
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
if (response.status === 401 && isRecord(body) && body.error === "invalid_device_session")
|
|
40
|
+
throw new CloudError("invalid_device_session");
|
|
41
|
+
throw new CloudError("cloud_unavailable");
|
|
42
|
+
}
|
|
43
|
+
return body;
|
|
44
|
+
}
|
|
45
|
+
export async function gatewayConfig(origin, token) {
|
|
46
|
+
const body = await cloudRequest(origin, "/v1/gateway/config", token);
|
|
47
|
+
if (!isRecord(body) || typeof body.gatewayUrl !== "string")
|
|
48
|
+
throw new CloudError("invalid_response");
|
|
49
|
+
const url = new URL(body.gatewayUrl);
|
|
50
|
+
if (url.protocol !== "wss:" || url.username || url.password || url.search || url.hash)
|
|
51
|
+
throw new CloudError("invalid_response");
|
|
52
|
+
return url.toString();
|
|
53
|
+
}
|
package/dist/connect.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { rm } from "node:fs/promises";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import { isRecord } from "./actions.js";
|
|
6
|
+
import { cloudOrigin, cloudRequest, CloudError, gatewayConfig } from "./cloud.js";
|
|
7
|
+
import { connectGateway } from "./client.js";
|
|
8
|
+
import { createNodeSocket } from "./socket.js";
|
|
9
|
+
import { loginDevice } from "./login.js";
|
|
10
|
+
import { readPrivateJson, writePrivateJson } from "./storage.js";
|
|
11
|
+
import { createExecutor } from "./executor.js";
|
|
12
|
+
import { validId } from "./protocol.js";
|
|
13
|
+
import { configRoot } from "./local.js";
|
|
14
|
+
export async function connectComputer(origin, name, signal) {
|
|
15
|
+
const cloudUrl = cloudOrigin(origin);
|
|
16
|
+
const path = join(configRoot(), "credential.json");
|
|
17
|
+
const stored = await readPrivateJson(path);
|
|
18
|
+
let session = isRecord(stored) &&
|
|
19
|
+
stored.cloudUrl === cloudUrl &&
|
|
20
|
+
typeof stored.token === "string" &&
|
|
21
|
+
/^romedev_[A-Za-z0-9_-]{43}$/.test(stored.token) &&
|
|
22
|
+
validId(stored.deviceId) &&
|
|
23
|
+
typeof stored.name === "string"
|
|
24
|
+
? { cloudUrl, token: stored.token, deviceId: stored.deviceId, name: stored.name }
|
|
25
|
+
: null;
|
|
26
|
+
let gatewayUrl = "";
|
|
27
|
+
let attempt = 0;
|
|
28
|
+
while (!signal.aborted) {
|
|
29
|
+
if (!session) {
|
|
30
|
+
process.stderr.write("While connect is running, Rome instances in your account can execute programs and read or change files as your OS user.\n");
|
|
31
|
+
session = { ...(await loginDevice(cloudUrl, name, signal)), name };
|
|
32
|
+
await writePrivateJson(path, session);
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
gatewayUrl = await gatewayConfig(cloudUrl, session.token);
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error instanceof CloudError && error.code === "invalid_device_session") {
|
|
40
|
+
await rm(path, { force: true });
|
|
41
|
+
session = null;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
process.stderr.write("Rome Cloud is unavailable; keeping credentials and retrying.\n");
|
|
45
|
+
await delay(Math.min(60_000, 1000 * 2 ** Math.min(attempt++, 6)), undefined, {
|
|
46
|
+
signal,
|
|
47
|
+
}).catch(() => { });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (signal.aborted || !session)
|
|
51
|
+
return;
|
|
52
|
+
const devices = await cloudRequest(cloudUrl, "/api/account/devices", session.token).catch(() => null);
|
|
53
|
+
if (isRecord(devices) && Array.isArray(devices.items)) {
|
|
54
|
+
const device = devices.items.find((item) => isRecord(item) && item.id === session?.deviceId);
|
|
55
|
+
if (isRecord(device) && typeof device.device_name === "string") {
|
|
56
|
+
session.name = device.device_name;
|
|
57
|
+
await writePrivateJson(path, session);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (signal.aborted)
|
|
61
|
+
return;
|
|
62
|
+
const active = session;
|
|
63
|
+
process.stderr.write(`Device: ${active.name}\nDevice ID: ${active.deviceId}\n`);
|
|
64
|
+
await new Promise((resolve, reject) => {
|
|
65
|
+
const executor = createExecutor(active.name, (message) => connection.send(message), [
|
|
66
|
+
active.token,
|
|
67
|
+
]);
|
|
68
|
+
const finish = (error) => {
|
|
69
|
+
signal.removeEventListener("abort", abort);
|
|
70
|
+
executor.disconnect();
|
|
71
|
+
connection.stop();
|
|
72
|
+
if (error)
|
|
73
|
+
reject(error);
|
|
74
|
+
else
|
|
75
|
+
resolve();
|
|
76
|
+
};
|
|
77
|
+
const abort = () => finish();
|
|
78
|
+
const connection = connectGateway({
|
|
79
|
+
gatewayUrl,
|
|
80
|
+
deviceToken: active.token,
|
|
81
|
+
createSocket: createNodeSocket,
|
|
82
|
+
beforeConnect: async () => {
|
|
83
|
+
try {
|
|
84
|
+
return await gatewayConfig(cloudUrl, active.token);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (error instanceof CloudError && error.code === "invalid_device_session")
|
|
88
|
+
return null;
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
onMessage: (message) => {
|
|
93
|
+
if ("from" in message)
|
|
94
|
+
void executor.receive(message);
|
|
95
|
+
},
|
|
96
|
+
onStatus: (status) => {
|
|
97
|
+
process.stderr.write(`Connection: ${status}\n`);
|
|
98
|
+
if (status !== "online" && status !== "connecting")
|
|
99
|
+
executor.disconnect();
|
|
100
|
+
if (status === "revoked" || status === "superseded") {
|
|
101
|
+
queueMicrotask(() => finish(new Error(status === "revoked"
|
|
102
|
+
? "Authorization revoked. Run rome-node connect again to authorize."
|
|
103
|
+
: "Another connection replaced this device. This process has stopped.")));
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
108
|
+
if (signal.aborted)
|
|
109
|
+
abort();
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
export const defaultDeviceName = () => hostname()
|
|
113
|
+
.replace(/[\u0000-\u001f\u007f]/g, "")
|
|
114
|
+
.slice(0, 80) || "Computer";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type ActionResponse } from "./actions.js";
|
|
2
|
+
import { connectGateway } from "./client.js";
|
|
3
|
+
import type { CallerCredential } from "./local.js";
|
|
4
|
+
export interface DeviceConnectorOptions {
|
|
5
|
+
credential: CallerCredential;
|
|
6
|
+
connect?: typeof connectGateway;
|
|
7
|
+
waitMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare class DeviceConnector {
|
|
10
|
+
private options;
|
|
11
|
+
private connection?;
|
|
12
|
+
private initializing?;
|
|
13
|
+
private status;
|
|
14
|
+
private stopped;
|
|
15
|
+
private pending;
|
|
16
|
+
private ready;
|
|
17
|
+
constructor(options: DeviceConnectorOptions);
|
|
18
|
+
private ensure;
|
|
19
|
+
private initialize;
|
|
20
|
+
list(): Promise<unknown>;
|
|
21
|
+
private receive;
|
|
22
|
+
run(target: string, action: string, args: unknown): Promise<ActionResponse>;
|
|
23
|
+
stop(): void;
|
|
24
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { actionError, isRecord, parseResponse, } from "./actions.js";
|
|
3
|
+
import { connectGateway } from "./client.js";
|
|
4
|
+
import { cloudRequest, CloudError, gatewayConfig } from "./cloud.js";
|
|
5
|
+
import { byteLength, MAX_MESSAGE_BYTES, validId } from "./protocol.js";
|
|
6
|
+
import { createNodeSocket } from "./socket.js";
|
|
7
|
+
export class DeviceConnector {
|
|
8
|
+
options;
|
|
9
|
+
connection;
|
|
10
|
+
initializing;
|
|
11
|
+
status = "stopped";
|
|
12
|
+
stopped = false;
|
|
13
|
+
pending = new Map();
|
|
14
|
+
ready = new Set();
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.options = options;
|
|
17
|
+
}
|
|
18
|
+
async ensure() {
|
|
19
|
+
if (this.stopped || this.status === "superseded" || this.status === "revoked")
|
|
20
|
+
throw new Error("connector_stopped");
|
|
21
|
+
if (this.connection)
|
|
22
|
+
return;
|
|
23
|
+
if (this.initializing)
|
|
24
|
+
return this.initializing;
|
|
25
|
+
this.initializing = this.initialize();
|
|
26
|
+
try {
|
|
27
|
+
await this.initializing;
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
this.initializing = undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
async initialize() {
|
|
34
|
+
const { cloudUrl, token } = this.options.credential;
|
|
35
|
+
const gatewayUrl = await gatewayConfig(cloudUrl, token);
|
|
36
|
+
if (this.stopped)
|
|
37
|
+
throw new Error("connector_stopped");
|
|
38
|
+
this.connection = (this.options.connect ?? connectGateway)({
|
|
39
|
+
gatewayUrl,
|
|
40
|
+
deviceToken: token,
|
|
41
|
+
createSocket: createNodeSocket,
|
|
42
|
+
beforeConnect: async () => {
|
|
43
|
+
try {
|
|
44
|
+
return await gatewayConfig(cloudUrl, token);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error instanceof CloudError && error.code === "invalid_device_session")
|
|
48
|
+
return null;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
onMessage: (message) => this.receive(message),
|
|
53
|
+
onStatus: (status) => {
|
|
54
|
+
this.status = status;
|
|
55
|
+
if (status !== "online") {
|
|
56
|
+
for (const pending of this.pending.values())
|
|
57
|
+
pending.finish(actionError("unknown_outcome", "The connection was lost. Execution may have occurred. Do not automatically retry."));
|
|
58
|
+
}
|
|
59
|
+
for (const wake of this.ready)
|
|
60
|
+
wake();
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async list() {
|
|
65
|
+
if (this.stopped)
|
|
66
|
+
throw new Error("connector_stopped");
|
|
67
|
+
const { cloudUrl, token } = this.options.credential;
|
|
68
|
+
const body = await cloudRequest(cloudUrl, "/api/account/devices", token);
|
|
69
|
+
if (!isRecord(body) || !Array.isArray(body.items))
|
|
70
|
+
throw new CloudError("invalid_response");
|
|
71
|
+
return { items: body.items };
|
|
72
|
+
}
|
|
73
|
+
receive(message) {
|
|
74
|
+
const pending = this.pending.get(message.id);
|
|
75
|
+
if (!pending)
|
|
76
|
+
return;
|
|
77
|
+
if ("type" in message) {
|
|
78
|
+
pending.finish(actionError(message.code, "The Gateway could not forward the request."));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (message.from !== pending.target)
|
|
82
|
+
return;
|
|
83
|
+
const response = parseResponse(message.payload);
|
|
84
|
+
if (response)
|
|
85
|
+
pending.finish(response);
|
|
86
|
+
}
|
|
87
|
+
async run(target, action, args) {
|
|
88
|
+
if (!validId(target) || !action || action.length > 128)
|
|
89
|
+
return actionError("invalid_request", "A device ID and action are required.");
|
|
90
|
+
try {
|
|
91
|
+
await this.ensure();
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return actionError("gateway_unavailable", "The device connection is unavailable.");
|
|
95
|
+
}
|
|
96
|
+
if (this.status !== "online")
|
|
97
|
+
await new Promise((resolve) => {
|
|
98
|
+
const finish = () => {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
this.ready.delete(wake);
|
|
101
|
+
resolve();
|
|
102
|
+
};
|
|
103
|
+
const wake = () => {
|
|
104
|
+
if (["online", "revoked", "superseded", "stopped"].includes(this.status))
|
|
105
|
+
finish();
|
|
106
|
+
};
|
|
107
|
+
const timer = setTimeout(finish, 10_000);
|
|
108
|
+
this.ready.add(wake);
|
|
109
|
+
wake();
|
|
110
|
+
});
|
|
111
|
+
if (this.status !== "online")
|
|
112
|
+
return actionError("gateway_unavailable", "The device connection is offline. No operation was sent.");
|
|
113
|
+
if (this.pending.size >= 128)
|
|
114
|
+
return actionError("busy", "Too many device requests are pending.");
|
|
115
|
+
const id = randomUUID();
|
|
116
|
+
const envelope = {
|
|
117
|
+
id,
|
|
118
|
+
to: target,
|
|
119
|
+
payload: { type: "request", action, args },
|
|
120
|
+
};
|
|
121
|
+
try {
|
|
122
|
+
if (byteLength(JSON.stringify(envelope)) > MAX_MESSAGE_BYTES)
|
|
123
|
+
return actionError("message_too_large", "The request exceeds 128 KiB.");
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return actionError("invalid_request", "Action arguments must be JSON.");
|
|
127
|
+
}
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
const finish = (response) => {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
this.pending.delete(id);
|
|
132
|
+
resolve(response);
|
|
133
|
+
};
|
|
134
|
+
const timer = setTimeout(() => finish(actionError("unknown_outcome", "The local wait limit was reached. The remote program was not canceled. Do not automatically retry.")), this.options.waitMs ?? 60_000);
|
|
135
|
+
this.pending.set(id, { target, finish });
|
|
136
|
+
if (!this.connection?.send(envelope))
|
|
137
|
+
finish(actionError("unknown_outcome", "The request could not be submitted reliably. Do not automatically retry."));
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
stop() {
|
|
141
|
+
this.stopped = true;
|
|
142
|
+
this.connection?.stop();
|
|
143
|
+
for (const pending of this.pending.values())
|
|
144
|
+
pending.finish(actionError("unknown_outcome", "The CLI daemon is shutting down. Execution may have occurred."));
|
|
145
|
+
for (const wake of this.ready)
|
|
146
|
+
wake();
|
|
147
|
+
}
|
|
148
|
+
}
|
package/dist/daemon.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
interface DaemonState {
|
|
2
|
+
token: string;
|
|
3
|
+
port: number;
|
|
4
|
+
pid: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function daemonStatus(): Promise<DaemonState | null>;
|
|
7
|
+
export declare function startDaemon(): Promise<DaemonState>;
|
|
8
|
+
export declare function stopDaemon(): Promise<void>;
|
|
9
|
+
export declare function callDaemon(path: "/devices" | "/run", body?: unknown): Promise<Response>;
|
|
10
|
+
export declare function serveDaemon(): Promise<void>;
|
|
11
|
+
export {};
|