@rome-os/node 0.1.2 → 0.1.4

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
@@ -60,6 +60,29 @@ rome-node device describe <device-id>
60
60
  rome-node device run <device-id> exec --args '{"command":"git","args":["status"],"cwd":"/workspace"}'
61
61
  ```
62
62
 
63
+ Copy a file to or from a device:
64
+
65
+ ```sh
66
+ rome-node cp ./video.mp4 <device-id>:/tmp/video.mp4
67
+ rome-node cp <device-id>:/tmp/video.mp4 ./video.mp4
68
+ ```
69
+
70
+ The caller daemon and `rome-node connect` read and write the files with Node
71
+ streams. Each copy writes its own hidden part file next to the destination.
72
+ The destination changes only after its size and SHA-256 checksum match and the
73
+ data is flushed to disk. A replaced file keeps its permission bits.
74
+ A failed copy leaves it unchanged and is safe to run again. To copy between two
75
+ devices, copy to this computer first and then to the second device. Progress
76
+ goes to stderr, and `{"bytes":...,"ms":...,"sha256":"..."}` goes to stdout. The
77
+ device needs a rome-node version with file transfer support.
78
+
79
+ To pass raw bytes to a program and read its raw stdout, add `--input` or `--output`
80
+ to `device run`:
81
+
82
+ ```sh
83
+ rome-node device run <device-id> exec --args '{"command":"gzip","args":["-c"]}' --input data.bin --output data.bin.gz
84
+ ```
85
+
63
86
  Use `rome-node <command> --help` or `-h` for command-specific help.
64
87
  Help includes action arguments, examples, output fields, and failure handling.
65
88
  It works offline without credentials and does not start the daemon.
@@ -76,10 +99,12 @@ After a connection loss it waits for the daemon and subscribes again; it does
76
99
  not restart a stopped daemon. Ctrl+C closes only the watcher. These events
77
100
  report the caller's Gateway connection, not remote device presence.
78
101
 
79
- Commands print JSON to stdout and diagnostics to stderr. Errors and nonzero
102
+ Commands print JSON to stdout and diagnostics to stderr. With `--input` and no
103
+ `--output <file>`, `device run` prints the program's raw stdout and moves the JSON
104
+ response to stderr. Errors and nonzero
80
105
  remote exit codes produce a nonzero local exit status. Output is collected in memory
81
106
  and returned in full after program exit. Action message limits follow the WebSocket library and infrastructure.
82
- The local daemon disconnects event observers that exceed its send-buffer limit. A lost response reports
107
+ The local daemon disconnects event observers that exceed its unsent event limit. A lost response reports
83
108
  `unknown_outcome`. Never automatically retry an unknown outcome.
84
109
  An authorized device is
85
110
  not necessarily online. Use `describe` to query its current platform and actions.
@@ -87,4 +112,4 @@ not necessarily online. Use `describe` to query its current platform and actions
87
112
  The CLI uses [`@rome-os/node-core`](../rome-node-core/README.md) for authorization,
88
113
  its shared caller daemon, and the computer host. The package also re-exports
89
114
  the Gateway client, socket adapter, and action response contract for compatibility. See [the device architecture and operating guide](../../docs/rome-node.md)
90
- for credential boundaries, file operations, output limits, and disconnection behavior.
115
+ for credential boundaries, binary frames, file copies, output limits, and disconnection behavior.
package/dist/copy.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { CopyRequest } from "@rome-os/node-core/client";
2
+ export interface CopyLocation {
3
+ deviceId?: string;
4
+ path: string;
5
+ }
6
+ export declare function parseLocation(value: string): CopyLocation;
7
+ export declare function copyRequest(source: string, destination: string, cwd: string): CopyRequest;
package/dist/copy.js ADDED
@@ -0,0 +1,43 @@
1
+ import { resolve } from "node:path";
2
+ import { isUuid } from "@rome-os/node-core";
3
+ export function parseLocation(value) {
4
+ const separator = value.indexOf(":");
5
+ const prefix = value.slice(0, separator);
6
+ if (separator === 36 && isUuid(prefix)) {
7
+ if (separator === value.length - 1)
8
+ throw new Error("A path is required after <device-id>:.");
9
+ return { deviceId: prefix.toLowerCase(), path: value.slice(separator + 1) };
10
+ }
11
+ if (!value)
12
+ throw new Error("A path is required.");
13
+ return { path: value };
14
+ }
15
+ export function copyRequest(source, destination, cwd) {
16
+ const from = parseLocation(source);
17
+ const to = parseLocation(destination);
18
+ if (from.deviceId && to.deviceId)
19
+ throw new Error("Copy between two devices in two steps through this computer: " +
20
+ "rome-node cp <device-a>:<path> <local-file>, then rome-node cp <local-file> <device-b>:<path>.");
21
+ if (!from.deviceId && !to.deviceId)
22
+ throw new Error("One side must be a device path: <device-id>:<path>.");
23
+ let target = to.path;
24
+ if (/[\\/]$/.test(target)) {
25
+ const name = from.path.split(/[\\/]/).filter(Boolean).pop();
26
+ if (!name)
27
+ throw new Error("The source path has no file name.");
28
+ target += name;
29
+ }
30
+ return from.deviceId
31
+ ? {
32
+ direction: "pull",
33
+ deviceId: from.deviceId,
34
+ remotePath: from.path,
35
+ localPath: resolve(cwd, target),
36
+ }
37
+ : {
38
+ direction: "push",
39
+ deviceId: to.deviceId,
40
+ remotePath: target,
41
+ localPath: resolve(cwd, from.path),
42
+ };
43
+ }
package/dist/entry.js CHANGED
@@ -1,9 +1,42 @@
1
1
  import { parseArgs } from "node:util";
2
2
  import { join } from "node:path";
3
+ import { readFile, writeFile } from "node:fs/promises";
3
4
  import { isRecord, cloudOrigin, validId } from "@rome-os/node-core";
4
5
  import { authorizeServer, configureCaller } from "@rome-os/node-core/auth";
5
6
  import { createNodeClient, CallerConfigurationError, DeviceActionError, nodeConfigFromEnvironment, readOptionalCallerCredential, daemonStatus, startDaemon, stopDaemon, } from "@rome-os/node-core/client";
6
7
  import { commandHelp } from "./help.js";
8
+ import { copyRequest } from "./copy.js";
9
+ const failed = (response) => !response.ok ||
10
+ (isRecord(response.result) &&
11
+ Object.hasOwn(response.result, "exitCode") &&
12
+ response.result.exitCode !== 0);
13
+ async function readInput(path) {
14
+ if (path !== "-")
15
+ return readFile(path);
16
+ const chunks = [];
17
+ for await (const chunk of process.stdin)
18
+ chunks.push(Buffer.from(chunk));
19
+ return Buffer.concat(chunks);
20
+ }
21
+ function write(stream, data) {
22
+ return new Promise((resolve, reject) => stream.write(data, (error) => (error ? reject(error) : resolve())));
23
+ }
24
+ function progressPrinter() {
25
+ const started = Date.now();
26
+ let last = 0;
27
+ const mib = (bytes) => (bytes / 1024 / 1024).toFixed(1);
28
+ return (progress, final = false) => {
29
+ const now = Date.now();
30
+ if (!final && now - last < 1000 && progress.bytes !== progress.total)
31
+ return;
32
+ last = now;
33
+ const percent = progress.total ? Math.floor((progress.bytes / progress.total) * 100) : 100;
34
+ const ms = now - started;
35
+ const rate = ms ? mib((progress.bytes / ms) * 1000) : "0.0";
36
+ const line = `${mib(progress.bytes)} / ${mib(progress.total)} MiB (${percent}%) ${rate} MiB/s`;
37
+ process.stderr.write(process.stderr.isTTY ? `\r${line}\x1b[K${final ? "\n" : ""}` : `${line}\n`);
38
+ };
39
+ }
7
40
  async function withShutdown(run) {
8
41
  const controller = new AbortController();
9
42
  const stop = () => controller.abort();
@@ -30,6 +63,8 @@ async function main() {
30
63
  cloud: { type: "string" },
31
64
  name: { type: "string" },
32
65
  args: { type: "string" },
66
+ input: { type: "string" },
67
+ output: { type: "string" },
33
68
  server: { type: "boolean" },
34
69
  "device-code": { type: "boolean" },
35
70
  },
@@ -44,6 +79,25 @@ async function main() {
44
79
  throw new Error("--server is only supported by rome-node auth.");
45
80
  if (values["device-code"] && !(positionals[0] === "connect" && positionals.length === 1))
46
81
  throw new Error("--device-code is only supported by rome-node connect.");
82
+ if ((values.input !== undefined || values.output !== undefined) &&
83
+ !(positionals[0] === "device" && positionals[1] === "run"))
84
+ throw new Error("--input and --output are only supported by rome-node device run.");
85
+ if (positionals[0] === "cp") {
86
+ if (positionals.length !== 3)
87
+ throw new Error("Usage: rome-node cp <src> <dst>");
88
+ const request = copyRequest(positionals[1], positionals[2], process.cwd());
89
+ const client = createNodeClient(config);
90
+ const print = progressPrinter();
91
+ try {
92
+ const summary = await client.copy(request, { onProgress: (progress) => print(progress) });
93
+ print({ bytes: summary.bytes, total: summary.bytes }, true);
94
+ process.stdout.write(`${JSON.stringify(summary)}\n`);
95
+ }
96
+ finally {
97
+ client.disconnect();
98
+ }
99
+ return;
100
+ }
47
101
  if (positionals[0] === "auth" && positionals[1] === "status" && positionals.length === 2) {
48
102
  const credential = await readOptionalCallerCredential(config);
49
103
  process.stdout.write(`${JSON.stringify(credential ? { configured: true, cloudUrl: credential.cloudUrl } : { configured: false })}\n`);
@@ -177,14 +231,24 @@ async function main() {
177
231
  process.stdout.write(`${JSON.stringify(await client.listDevices())}\n`);
178
232
  return;
179
233
  }
234
+ if (values.input !== undefined || values.output !== undefined) {
235
+ const output = values.output ?? "-";
236
+ const input = values.input === undefined ? undefined : await readInput(values.input);
237
+ const { response, body } = await client.runBinary(deviceId, action, args, input);
238
+ if (output === "-")
239
+ await write(process.stdout, body);
240
+ else if (response.ok)
241
+ await writeFile(output, body);
242
+ await write(output === "-" ? process.stderr : process.stdout, `${JSON.stringify(response)}\n`);
243
+ if (failed(response))
244
+ process.exitCode = 1;
245
+ return;
246
+ }
180
247
  const result = command === "describe"
181
248
  ? await client.describe(deviceId)
182
249
  : await client.run(deviceId, action, args);
183
250
  process.stdout.write(`${JSON.stringify(result)}\n`);
184
- if (!result.ok ||
185
- (isRecord(result.result) &&
186
- Object.hasOwn(result.result, "exitCode") &&
187
- result.result.exitCode !== 0))
251
+ if (failed(result))
188
252
  process.exitCode = 1;
189
253
  }
190
254
  finally {
package/dist/help.js CHANGED
@@ -11,6 +11,7 @@ Commands:
11
11
  device List authorized devices (they may be offline).
12
12
  device describe Query a device's platform and supported actions.
13
13
  device run Execute an action on a device.
14
+ cp Copy a file to or from a device.
14
15
  watch Stream caller connection snapshots and changes as JSON lines.
15
16
  daemon Manage the caller's background connection.
16
17
 
@@ -95,7 +96,7 @@ The token is never printed.
95
96
  device: `Usage:
96
97
  rome-node device
97
98
  rome-node device describe <device-id>
98
- rome-node device run <device-id> <action> [--args <json>]
99
+ rome-node device run <device-id> <action> [--args <json>] [--input <file|->] [--output <file|->]
99
100
 
100
101
  With no subcommand, list authorized devices as {"items":[...]}.
101
102
  Use an item's id for subsequent commands. Listed devices may be offline.
@@ -119,7 +120,9 @@ Example result:
119
120
  Failures return {"type":"response","ok":false,"error":{"code":"...","message":"..."}}.
120
121
  The local exit status is 0 on success or 1 on failure.
121
122
  `,
122
- "device run": `Usage: rome-node device run <device-id> <action> [--args <json>]
123
+ "device run": `Usage:
124
+ rome-node device run <device-id> <action> [--args <json>]
125
+ rome-node device run <device-id> <action> [--args <json>] [--input <file|->] [--output <file|->]
123
126
 
124
127
  Use an ID from rome-node device. Run device describe first to discover actions.
125
128
  --args is a JSON object for the action (default: {}).
@@ -133,7 +136,7 @@ exec arguments:
133
136
  args Optional array of string arguments (default: []).
134
137
  cwd Optional working directory on the target (default: executor cwd).
135
138
 
136
- Programs receive no interactive stdin. No shell is added automatically.
139
+ Programs receive no stdin unless --input is given. No shell is added automatically.
137
140
  Invoke a shell explicitly for pipelines, redirection, or shell builtins.
138
141
  Examples below use POSIX caller-shell quoting:
139
142
  rome-node device run <device-id> system.info
@@ -151,12 +154,66 @@ Check exec's exitCode even when ok is true. The local exit status is 1 for
151
154
  action failures, nonzero remote exit codes, or signal termination, otherwise 0.
152
155
  Local setup and argument errors print diagnostics to stderr and exit 1.
153
156
  Output is collected in memory and returned in full after program exit.
154
- The CLI does not truncate output or impose message or send-buffer size limits.
155
- Transport limits still apply. Connection loss returns unknown_outcome.
157
+ In text mode, the CLI does not truncate output or impose message or send-buffer
158
+ size limits. Transport limits still apply. Connection loss returns unknown_outcome.
159
+
160
+ Raw bytes (--input or --output):
161
+ The request travels as a binary frame. Bytes are never JSON or base64 encoded.
162
+ --input <file|-> Send the file, or this command's stdin, as the program's stdin.
163
+ The program then reads end of file. Without --input, stdin is empty.
164
+ --output <file|-> Write the program's stdout bytes to the file, or to stdout (default: -).
165
+ The file is written only when ok is true.
166
+ The JSON response goes to stdout, or to stderr when the output is -.
167
+ exec result: {exitCode, signal, stderr, truncated}. stdout is the raw output.
168
+ Secrets in stdout are replaced by the same number of * bytes.
169
+ Each request and each response is one frame of at most 32 MiB. Larger input
170
+ fails with message_too_large before anything is sent. Larger output fails with
171
+ output_too_large after the program ran. Use rome-node cp for large files.
172
+ A target without binary frame support (no frameVersion in system.info) drops
173
+ the request, and the caller reports unknown_outcome after 60 seconds.
174
+ device run does not check frameVersion. rome-node cp checks transferVersion
175
+ first and fails at once with unsupported_device.
176
+ Example:
177
+ rome-node device run <device-id> exec --args '{"command":"gzip","args":["-c"]}' --input data.bin --output data.bin.gz
156
178
 
157
179
  The caller waits up to 60 seconds for a response. unknown_outcome means execution
158
180
  may have started or completed. A local timeout does not cancel the remote program.
159
181
  Never automatically retry an unknown outcome. Requests are never replayed.
182
+ `,
183
+ cp: `Usage: rome-node cp <src> <dst>
184
+
185
+ Copy one regular file between this computer and a device. Exactly one side is
186
+ <device-id>:<path>, and the other is a local path. A device path that is not
187
+ absolute starts from the working directory of rome-node connect on that device.
188
+ A destination ending in / or \\ receives the source file name.
189
+
190
+ rome-node cp ./video.mp4 <device-id>:/tmp/video.mp4
191
+ rome-node cp <device-id>:/tmp/video.mp4 ./video.mp4
192
+ rome-node cp ./video.mp4 '<device-id>:C:/Users/me/'
193
+
194
+ To copy between two devices, copy in two steps through this computer:
195
+ rome-node cp <device-a>:/data/video.mp4 ./video.mp4
196
+ rome-node cp ./video.mp4 <device-b>:/data/video.mp4
197
+
198
+ The caller daemon reads or writes the local file, and rome-node connect reads
199
+ or writes the device file. Programs and shells are not involved. The source
200
+ must be a regular file. FIFOs, devices, and directories fail with not_a_file.
201
+
202
+ Each copy writes its own .<name>.<uuid>.rome-part next to <dst>, created with
203
+ mode 0600. It replaces <dst> only after the size and SHA-256 checksum match the
204
+ source and the data is flushed to disk. A replaced file keeps its permission
205
+ bits, and a new file gets the directory's default mode.
206
+
207
+ A failed or interrupted copy (including Ctrl+C) leaves <dst> unchanged and
208
+ deletes its part file. There is no resume or automatic retry. Run the copy
209
+ again. A copy aborts after 60 seconds without progress. The device must run a
210
+ rome-node version that reports transferVersion in system.info.
211
+
212
+ Output:
213
+ Progress (bytes, percent, throughput) goes to stderr.
214
+ Success prints {"bytes":...,"ms":...,"sha256":"..."} to stdout and exits 0.
215
+ A copy failure prints {"type":"response","ok":false,"error":{...}} to stdout
216
+ and exits 1. Argument errors print to stderr and exit 1.
160
217
  `,
161
218
  daemon: `Usage: rome-node daemon [start|status|stop|serve]
162
219
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rome-os/node",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Standalone CLI for Rome remote devices",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,7 +22,7 @@
22
22
  }
23
23
  },
24
24
  "dependencies": {
25
- "@rome-os/node-core": "^0.1.1"
25
+ "@rome-os/node-core": "^0.1.3"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@rstest/core": "^0.11.11",