@rome-os/node-core 0.1.3 → 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 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
@@ -190,7 +199,9 @@ await running;
190
199
  ```
191
200
 
192
201
  `showAuthorizationUrl` and `updateApplicationState` are application callbacks.
193
- A custom credential store implements `load`, `save`, and `clear`. The built-in
202
+ A custom credential store implements `load`, `save`, and `clear`. A stored token
203
+ is either a computer credential from `loginDevice` or `loginDeviceCode`, or the
204
+ Rome desktop app's own Rome Cloud sign-in. The built-in
194
205
  login adapter uses a temporary loopback callback listener. Applications can
195
206
  supply their own authorization function returning a device session.
196
207
 
@@ -201,7 +212,7 @@ where `expiresIn` is in seconds. It returns a `DeviceSession` after approval and
201
212
  rejects on denial, expiry, cancellation, or a terminal error. It opens no browser
202
213
  or callback listener. The callback receives neither the device code nor the access token.
203
214
 
204
- The host exposes `system.info` and `exec`, in text envelopes and in binary frames.
215
+ The host exposes `system.info`, `exec`, and the read-only `fs.list`, in text envelopes and in binary frames.
205
216
  It also accepts the internal file transfers that `copy()` uses. Programs run as the application's OS
206
217
  user. Connection loss terminates direct child processes on a best-effort basis.
207
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 === "invalid_device_session"
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: { Authorization: `Bearer ${token}` },
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/connect.js CHANGED
@@ -15,7 +15,7 @@ export async function connectHost(options) {
15
15
  let session = isRecord(stored) &&
16
16
  stored.cloudUrl === cloudUrl &&
17
17
  typeof stored.token === "string" &&
18
- /^romedev_[A-Za-z0-9_-]{43}$/.test(stored.token) &&
18
+ /^(?:romedev|romemob)_[A-Za-z0-9_-]{43}$/.test(stored.token) &&
19
19
  validId(stored.deviceId) &&
20
20
  typeof stored.name === "string"
21
21
  ? { cloudUrl, token: stored.token, deviceId: stored.deviceId, name: stored.name }
@@ -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)
@@ -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?: {
@@ -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.");
@@ -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 { platform } from "node:os";
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rome-os/node-core",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "JavaScript APIs for Rome device hosts and shared caller daemons",
5
5
  "license": "MIT",
6
6
  "type": "module",