@rome-os/node-core 0.1.4 → 0.1.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 +10 -1
- package/dist/cloud.d.ts +1 -1
- package/dist/cloud.js +16 -5
- package/dist/connector.d.ts +2 -0
- package/dist/connector.js +16 -0
- package/dist/daemon-client.d.ts +2 -0
- package/dist/daemon-client.js +34 -0
- package/dist/daemon.js +23 -0
- package/dist/executor.d.ts +1 -0
- package/dist/executor.js +57 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,6 +21,8 @@ const client = createNodeClient(nodeConfigFromEnvironment(process.env));
|
|
|
21
21
|
const status = await client.getConnectionStatus();
|
|
22
22
|
const devices = await client.listDevices();
|
|
23
23
|
const connections = await client.getDevicesStatus();
|
|
24
|
+
await client.renameDevice("device-id", "Build server");
|
|
25
|
+
await client.removeDevice("old-device-id");
|
|
24
26
|
const info = await client.describe("device-id");
|
|
25
27
|
const result = await client.run("device-id", "exec", {
|
|
26
28
|
command: "git",
|
|
@@ -48,6 +50,13 @@ Gateway connection. `connection` describes the caller connection to Gateway,
|
|
|
48
50
|
not the online state of remote devices. `listDevices()` returns authorized
|
|
49
51
|
devices without asserting that they are online.
|
|
50
52
|
|
|
53
|
+
`renameDevice(deviceId, name)` and `removeDevice(deviceId)` change the account's
|
|
54
|
+
device list in Cloud and resolve with no value. A failure throws
|
|
55
|
+
`DeviceActionError` with code `device_not_found`, `invalid_device_name`,
|
|
56
|
+
`invalid_device_session`, or `cloud_unavailable`. `removeDevice` revokes the
|
|
57
|
+
device first and throws `revocation_pending` while the Gateway has not confirmed
|
|
58
|
+
the revocation. The device stays listed as revoked until a later call deletes it.
|
|
59
|
+
|
|
51
60
|
`runBinary(deviceId, action, args?, body?)` resolves to `{ response, body }`.
|
|
52
61
|
It sends `body` as input bytes in a binary frame. For `exec`, the input is the
|
|
53
62
|
program's stdin and the reply `body` is its raw stdout. Neither side is JSON or
|
|
@@ -203,7 +212,7 @@ where `expiresIn` is in seconds. It returns a `DeviceSession` after approval and
|
|
|
203
212
|
rejects on denial, expiry, cancellation, or a terminal error. It opens no browser
|
|
204
213
|
or callback listener. The callback receives neither the device code nor the access token.
|
|
205
214
|
|
|
206
|
-
The host exposes `system.info` and `
|
|
215
|
+
The host exposes `system.info`, `exec`, and the read-only `fs.list`, in text envelopes and in binary frames.
|
|
207
216
|
It also accepts the internal file transfers that `copy()` uses. Programs run as the application's OS
|
|
208
217
|
user. Connection loss terminates direct child processes on a best-effort basis.
|
|
209
218
|
A local caller timeout does not cancel remote execution.
|
package/dist/cloud.d.ts
CHANGED
|
@@ -3,5 +3,5 @@ export declare class CloudError extends Error {
|
|
|
3
3
|
constructor(code: string);
|
|
4
4
|
}
|
|
5
5
|
export declare function cloudOrigin(value: string): string;
|
|
6
|
-
export declare function cloudRequest(origin: string, path: string, token: string, method?: string): Promise<unknown>;
|
|
6
|
+
export declare function cloudRequest(origin: string, path: string, token: string, method?: string, json?: unknown): Promise<unknown>;
|
|
7
7
|
export declare function gatewayConfig(origin: string, token: string): Promise<string>;
|
package/dist/cloud.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { isRecord } from "./actions.js";
|
|
2
2
|
import { parseGatewayUrl } from "./gateway-url.js";
|
|
3
|
+
const messages = {
|
|
4
|
+
invalid_device_session: "Device authorization is invalid.",
|
|
5
|
+
device_not_found: "No device with this ID exists in the account.",
|
|
6
|
+
invalid_device_name: "Use a name of 1 to 80 characters without control characters.",
|
|
7
|
+
revocation_pending: "The device is revoked. Rome Cloud deletes it after the Gateway confirms the revocation. Remove it again later.",
|
|
8
|
+
};
|
|
9
|
+
const deviceErrors = new Set(["device_not_found", "invalid_device_name", "revocation_pending"]);
|
|
3
10
|
export class CloudError extends Error {
|
|
4
11
|
code;
|
|
5
12
|
constructor(code) {
|
|
6
|
-
super(code
|
|
7
|
-
? "Device authorization is invalid."
|
|
8
|
-
: "Rome Cloud is unavailable. Retry later.");
|
|
13
|
+
super(messages[code] ?? "Rome Cloud is unavailable. Retry later.");
|
|
9
14
|
this.code = code;
|
|
10
15
|
}
|
|
11
16
|
}
|
|
@@ -21,12 +26,16 @@ export function cloudOrigin(value) {
|
|
|
21
26
|
throw new Error("Use an HTTPS Cloud origin. HTTP is allowed only on loopback.");
|
|
22
27
|
return url.origin;
|
|
23
28
|
}
|
|
24
|
-
export async function cloudRequest(origin, path, token, method = "GET") {
|
|
29
|
+
export async function cloudRequest(origin, path, token, method = "GET", json) {
|
|
25
30
|
let response;
|
|
26
31
|
try {
|
|
27
32
|
response = await fetch(new URL(path, cloudOrigin(origin)), {
|
|
28
33
|
method,
|
|
29
|
-
headers: {
|
|
34
|
+
headers: {
|
|
35
|
+
Authorization: `Bearer ${token}`,
|
|
36
|
+
...(json === undefined ? {} : { "content-type": "application/json" }),
|
|
37
|
+
},
|
|
38
|
+
body: json === undefined ? undefined : JSON.stringify(json),
|
|
30
39
|
redirect: "error",
|
|
31
40
|
signal: AbortSignal.timeout(10_000),
|
|
32
41
|
cache: "no-store",
|
|
@@ -39,6 +48,8 @@ export async function cloudRequest(origin, path, token, method = "GET") {
|
|
|
39
48
|
if (!response.ok) {
|
|
40
49
|
if (response.status === 401 && isRecord(body) && body.error === "invalid_device_session")
|
|
41
50
|
throw new CloudError("invalid_device_session");
|
|
51
|
+
if (isRecord(body) && typeof body.error === "string" && deviceErrors.has(body.error))
|
|
52
|
+
throw new CloudError(body.error);
|
|
42
53
|
throw new CloudError("cloud_unavailable");
|
|
43
54
|
}
|
|
44
55
|
return body;
|
package/dist/connector.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export declare class DeviceConnector {
|
|
|
22
22
|
private ensure;
|
|
23
23
|
private initialize;
|
|
24
24
|
list(): Promise<unknown>;
|
|
25
|
+
rename(deviceId: string, name: string): Promise<void>;
|
|
26
|
+
remove(deviceId: string): Promise<void>;
|
|
25
27
|
private receive;
|
|
26
28
|
private receiveFrame;
|
|
27
29
|
private admit;
|
package/dist/connector.js
CHANGED
|
@@ -80,6 +80,22 @@ export class DeviceConnector {
|
|
|
80
80
|
throw new CloudError("invalid_response");
|
|
81
81
|
return { items: body.items };
|
|
82
82
|
}
|
|
83
|
+
async rename(deviceId, name) {
|
|
84
|
+
if (this.stopped)
|
|
85
|
+
throw new Error("connector_stopped");
|
|
86
|
+
if (!isUuid(deviceId))
|
|
87
|
+
throw new CloudError("device_not_found");
|
|
88
|
+
const { cloudUrl, token } = this.options.credential;
|
|
89
|
+
await cloudRequest(cloudUrl, `/api/account/devices/${deviceId}`, token, "PATCH", { name });
|
|
90
|
+
}
|
|
91
|
+
async remove(deviceId) {
|
|
92
|
+
if (this.stopped)
|
|
93
|
+
throw new Error("connector_stopped");
|
|
94
|
+
if (!isUuid(deviceId))
|
|
95
|
+
throw new CloudError("device_not_found");
|
|
96
|
+
const { cloudUrl, token } = this.options.credential;
|
|
97
|
+
await cloudRequest(cloudUrl, `/api/account/devices/${deviceId}`, token, "DELETE");
|
|
98
|
+
}
|
|
83
99
|
receive(message) {
|
|
84
100
|
const pending = this.pending.get(message.id);
|
|
85
101
|
if (!pending || pending.binary)
|
package/dist/daemon-client.d.ts
CHANGED
|
@@ -32,6 +32,8 @@ export declare function createNodeClient(config: NodeConfig, options?: NodeClien
|
|
|
32
32
|
getConnectionStatus(): Promise<DaemonStatus | null>;
|
|
33
33
|
getDevicesStatus(): Promise<DevicesStatus | null>;
|
|
34
34
|
listDevices(): Promise<DeviceList>;
|
|
35
|
+
renameDevice: (deviceId: string, name: string) => Promise<void>;
|
|
36
|
+
removeDevice: (deviceId: string) => Promise<void>;
|
|
35
37
|
run: (deviceId: string, action: string, args?: unknown) => Promise<ActionResponse>;
|
|
36
38
|
runBinary: (deviceId: string, action: string, args?: unknown, body?: Uint8Array) => Promise<BinaryActionResult>;
|
|
37
39
|
copy: (request: CopyRequest, options?: {
|
package/dist/daemon-client.js
CHANGED
|
@@ -146,6 +146,38 @@ export function createNodeClient(config, options = {}) {
|
|
|
146
146
|
throw error;
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
|
+
async function manage(method, params) {
|
|
150
|
+
assertOpen();
|
|
151
|
+
let value;
|
|
152
|
+
try {
|
|
153
|
+
value = await call(method, params);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (error instanceof RpcError && error.code === -32601)
|
|
157
|
+
throw new DaemonVersionError();
|
|
158
|
+
if (error instanceof DaemonRequestError)
|
|
159
|
+
throw new DeviceActionError({
|
|
160
|
+
type: "response",
|
|
161
|
+
ok: false,
|
|
162
|
+
error: { code: error.code, message: error.message },
|
|
163
|
+
});
|
|
164
|
+
if (error instanceof RpcError)
|
|
165
|
+
throw new DeviceActionError({
|
|
166
|
+
type: "response",
|
|
167
|
+
ok: false,
|
|
168
|
+
error: {
|
|
169
|
+
code: error.code === -32602 ? "invalid_request" : "cloud_unavailable",
|
|
170
|
+
message: error.message,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
const response = parseResponse(value);
|
|
176
|
+
if (!response)
|
|
177
|
+
throw new Error("Invalid device management response.");
|
|
178
|
+
if (!response.ok)
|
|
179
|
+
throw new DeviceActionError(response);
|
|
180
|
+
}
|
|
149
181
|
async function runBinary(deviceId, action, args = {}, body = new Uint8Array()) {
|
|
150
182
|
assertOpen();
|
|
151
183
|
const empty = new Uint8Array();
|
|
@@ -281,6 +313,8 @@ export function createNodeClient(config, options = {}) {
|
|
|
281
313
|
throw new Error("Invalid device list response.");
|
|
282
314
|
return body;
|
|
283
315
|
},
|
|
316
|
+
renameDevice: (deviceId, name) => manage("devices.rename", { deviceId, name }),
|
|
317
|
+
removeDevice: (deviceId) => manage("devices.remove", { deviceId }),
|
|
284
318
|
run,
|
|
285
319
|
runBinary,
|
|
286
320
|
copy,
|
package/dist/daemon.js
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import WebSocket, { WebSocketServer } from "ws";
|
|
6
6
|
import { actionError, isRecord } from "./actions.js";
|
|
7
7
|
import { ObserveStore, QueryError } from "./observe.js";
|
|
8
|
+
import { CloudError } from "./cloud.js";
|
|
8
9
|
import { DeviceConnector } from "./connector.js";
|
|
9
10
|
import { daemonStatePath, readCallerCredential } from "./local.js";
|
|
10
11
|
import { writePrivateJson } from "./storage.js";
|
|
@@ -364,6 +365,28 @@ export async function serveDaemon(config, signal) {
|
|
|
364
365
|
case "devices.list":
|
|
365
366
|
result = await connector.list();
|
|
366
367
|
break;
|
|
368
|
+
case "devices.rename":
|
|
369
|
+
case "devices.remove": {
|
|
370
|
+
if (typeof params.deviceId !== "string" ||
|
|
371
|
+
(value.method === "devices.rename" && typeof params.name !== "string"))
|
|
372
|
+
throw new RpcError(-32602, "A device ID and, for rename, a name are required.");
|
|
373
|
+
try {
|
|
374
|
+
if (value.method === "devices.rename")
|
|
375
|
+
await connector.rename(params.deviceId, params.name);
|
|
376
|
+
else
|
|
377
|
+
await connector.remove(params.deviceId);
|
|
378
|
+
result = { type: "response", ok: true, result: {} };
|
|
379
|
+
}
|
|
380
|
+
catch (cause) {
|
|
381
|
+
if (!(cause instanceof CloudError))
|
|
382
|
+
throw cause;
|
|
383
|
+
result = actionError(cause.code, cause.message);
|
|
384
|
+
}
|
|
385
|
+
finally {
|
|
386
|
+
deviceStatus.invalidate();
|
|
387
|
+
}
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
367
390
|
case "devices.run":
|
|
368
391
|
if (typeof params.deviceId !== "string" || typeof params.action !== "string")
|
|
369
392
|
throw new RpcError(-32602, "A device ID and action are required.");
|
package/dist/executor.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Frame } from "./frame.js";
|
|
2
2
|
import type { InboundEnvelope, OutboundEnvelope } from "./protocol.js";
|
|
3
|
+
export declare const FS_LIST_LIMIT = 1000;
|
|
3
4
|
export declare function redactBytes(data: Buffer, secrets: string[]): Buffer;
|
|
4
5
|
export declare function createExecutor(name: string, send: (message: OutboundEnvelope) => boolean, secrets?: string[], sendFrame?: (frame: Frame) => boolean): {
|
|
5
6
|
receive(message: InboundEnvelope): Promise<void>;
|
package/dist/executor.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { readdir, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { homedir, platform } from "node:os";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
3
5
|
import { actionError, isRecord } from "./actions.js";
|
|
4
6
|
import { decodeMeta, encodeMeta, FRAME_TYPE, frameFits, MAX_FRAME_BYTES, } from "./frame.js";
|
|
5
7
|
import { parseTransferMessage, TRANSFER_VERSION, TransferHost } from "./transfer.js";
|
|
@@ -19,6 +21,57 @@ function execInput(input) {
|
|
|
19
21
|
cwd: input.cwd,
|
|
20
22
|
};
|
|
21
23
|
}
|
|
24
|
+
export const FS_LIST_LIMIT = 1000;
|
|
25
|
+
const typeOrder = { dir: 0, symlink: 1, file: 2, other: 3 };
|
|
26
|
+
function fsError(error) {
|
|
27
|
+
const code = isRecord(error) ? error.code : undefined;
|
|
28
|
+
if (code === "ENOENT")
|
|
29
|
+
return actionError("not_found", "The folder does not exist.");
|
|
30
|
+
if (code === "EACCES" || code === "EPERM")
|
|
31
|
+
return actionError("permission_denied", "The computer's user cannot read this folder.");
|
|
32
|
+
if (code === "ENOTDIR")
|
|
33
|
+
return actionError("not_a_directory", "The path is not a folder.");
|
|
34
|
+
return actionError("fs_failed", "Could not read the folder.");
|
|
35
|
+
}
|
|
36
|
+
async function listFolder(input) {
|
|
37
|
+
const raw = input === undefined || input === null ? {} : input;
|
|
38
|
+
if (!isRecord(raw) ||
|
|
39
|
+
(raw.path !== undefined &&
|
|
40
|
+
(typeof raw.path !== "string" || !raw.path || raw.path.includes("\0"))))
|
|
41
|
+
return actionError("invalid_args", "fs.list takes an optional non-empty string path.");
|
|
42
|
+
let path;
|
|
43
|
+
let entries;
|
|
44
|
+
try {
|
|
45
|
+
path = await realpath(resolve(homedir(), raw.path ?? "."));
|
|
46
|
+
if (!(await stat(path)).isDirectory())
|
|
47
|
+
return actionError("not_a_directory", "The path is not a folder.");
|
|
48
|
+
entries = (await readdir(path, { withFileTypes: true })).map((entry) => ({
|
|
49
|
+
name: entry.name,
|
|
50
|
+
type: entry.isDirectory()
|
|
51
|
+
? "dir"
|
|
52
|
+
: entry.isFile()
|
|
53
|
+
? "file"
|
|
54
|
+
: entry.isSymbolicLink()
|
|
55
|
+
? "symlink"
|
|
56
|
+
: "other",
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
return fsError(error);
|
|
61
|
+
}
|
|
62
|
+
entries.sort((a, b) => typeOrder[a.type] - typeOrder[b.type] || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
63
|
+
const parent = dirname(path);
|
|
64
|
+
return {
|
|
65
|
+
type: "response",
|
|
66
|
+
ok: true,
|
|
67
|
+
result: {
|
|
68
|
+
path,
|
|
69
|
+
parent: parent === path ? null : parent,
|
|
70
|
+
entries: entries.slice(0, FS_LIST_LIMIT),
|
|
71
|
+
truncated: entries.length > FS_LIST_LIMIT,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
22
75
|
export function redactBytes(data, secrets) {
|
|
23
76
|
for (const secret of secrets) {
|
|
24
77
|
const needle = Buffer.from(secret);
|
|
@@ -39,7 +92,7 @@ export function createExecutor(name, send, secrets = [], sendFrame = () => false
|
|
|
39
92
|
result: {
|
|
40
93
|
name,
|
|
41
94
|
platform: platform() === "darwin" ? "macos" : platform() === "win32" ? "windows" : platform(),
|
|
42
|
-
actions: ["system.info", "exec"],
|
|
95
|
+
actions: ["system.info", "exec", "fs.list"],
|
|
43
96
|
frameVersion: 1,
|
|
44
97
|
transferVersion: TRANSFER_VERSION,
|
|
45
98
|
},
|
|
@@ -113,10 +166,12 @@ export function createExecutor(name, send, secrets = [], sendFrame = () => false
|
|
|
113
166
|
},
|
|
114
167
|
};
|
|
115
168
|
},
|
|
169
|
+
"fs.list": listFolder,
|
|
116
170
|
};
|
|
117
171
|
const empty = new Uint8Array();
|
|
118
172
|
const frameHandlers = {
|
|
119
173
|
"system.info": async () => ({ response: info(), body: empty }),
|
|
174
|
+
"fs.list": async (input) => ({ response: await listFolder(input), body: empty }),
|
|
120
175
|
exec: async (input, body) => {
|
|
121
176
|
const parsed = execInput(input);
|
|
122
177
|
if (!parsed)
|