edge-ai-client-ts 1.0.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/CHANGELOG.md +72 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/bin/ec-ts.js +18 -0
- package/dist/buffer/disk-queue.d.ts +140 -0
- package/dist/buffer/disk-queue.js +370 -0
- package/dist/cli/devices.d.ts +1 -0
- package/dist/cli/devices.js +61 -0
- package/dist/cli/enroll.d.ts +2 -0
- package/dist/cli/enroll.js +89 -0
- package/dist/cli/index.d.ts +10 -0
- package/dist/cli/index.js +116 -0
- package/dist/cli/messages.d.ts +1 -0
- package/dist/cli/messages.js +59 -0
- package/dist/cli/run.d.ts +5 -0
- package/dist/cli/run.js +112 -0
- package/dist/cli/status.d.ts +1 -0
- package/dist/cli/status.js +56 -0
- package/dist/cli/whoami.d.ts +2 -0
- package/dist/cli/whoami.js +41 -0
- package/dist/config/cmd-gate.d.ts +65 -0
- package/dist/config/cmd-gate.js +128 -0
- package/dist/config/settings.d.ts +209 -0
- package/dist/config/settings.js +627 -0
- package/dist/crypto/aes-gcm.d.ts +38 -0
- package/dist/crypto/aes-gcm.js +90 -0
- package/dist/crypto/hmac.d.ts +31 -0
- package/dist/crypto/hmac.js +52 -0
- package/dist/crypto/tls-guard.d.ts +36 -0
- package/dist/crypto/tls-guard.js +54 -0
- package/dist/daemon/manager.d.ts +82 -0
- package/dist/daemon/manager.js +461 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +63 -0
- package/dist/logging/file-logger.d.ts +21 -0
- package/dist/logging/file-logger.js +71 -0
- package/dist/network/ws-client.d.ts +221 -0
- package/dist/network/ws-client.js +1134 -0
- package/dist/session/fail-fast.d.ts +70 -0
- package/dist/session/fail-fast.js +122 -0
- package/dist/session/manager.d.ts +136 -0
- package/dist/session/manager.js +291 -0
- package/dist/session/persistence.d.ts +103 -0
- package/dist/session/persistence.js +194 -0
- package/dist/session/pi-rpc.d.ts +164 -0
- package/dist/session/pi-rpc.js +412 -0
- package/dist/session/sftp.d.ts +64 -0
- package/dist/session/sftp.js +335 -0
- package/dist/session/shell-frame.d.ts +77 -0
- package/dist/session/shell-frame.js +199 -0
- package/dist/session/shell.d.ts +124 -0
- package/dist/session/shell.js +300 -0
- package/docs/CONFIGURATION.md +169 -0
- package/docs/INSTALLATION.md +164 -0
- package/docs/PROTOCOL.md +248 -0
- package/docs/TROUBLESHOOTING.md +177 -0
- package/package.json +79 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disk-backed offline buffer queue — TypeScript parallel of
|
|
3
|
+
* `edge-client/src/buffer/queue.rs` (in-memory queue with the disk-persist
|
|
4
|
+
* half) and `edge-client/src/buffer/disk_dump.rs` (AES-256-GCM envelope).
|
|
5
|
+
*
|
|
6
|
+
* This is the OFFLINE RESILIENCE LAYER: when the WS connection is down,
|
|
7
|
+
* stream frames are durably written to disk; on reconnect they replay in
|
|
8
|
+
* order; no frames lost on crash.
|
|
9
|
+
*
|
|
10
|
+
* ## On-disk format (mirrors Rust `DiskDumper`)
|
|
11
|
+
*
|
|
12
|
+
* [KEY_VERSION: 1 byte][AES-256-GCM nonce: 12 bytes][ciphertext + auth tag: 16 bytes]
|
|
13
|
+
*
|
|
14
|
+
* The plaintext is a JSON-encoded array of {@link QueueEntry} objects
|
|
15
|
+
* (`{ msg_id, timestamp, bytes }`), where `bytes` is base64-encoded.
|
|
16
|
+
*
|
|
17
|
+
* ## Atomic-write invariant (the project's hardest-won guarantee)
|
|
18
|
+
*
|
|
19
|
+
* 1. Serialize entries → encrypt → write to `<dir>/.buffer.queue.enc.tmp`
|
|
20
|
+
* 2. `fsync` the temp file
|
|
21
|
+
* 3. `rename` tmp → `<dir>/buffer.queue.enc`
|
|
22
|
+
*
|
|
23
|
+
* Crash safety: a tmp file left on disk after a crash is half-written; we
|
|
24
|
+
* delete it on next lazy-load. The main file is either fully written or
|
|
25
|
+
* absent (the rename is atomic on POSIX and on Windows since Node 14+).
|
|
26
|
+
*
|
|
27
|
+
* ## Concurrency
|
|
28
|
+
*
|
|
29
|
+
* In-process serialization via a Promise-chain mutex. Cross-process locking
|
|
30
|
+
* is intentionally NOT implemented (would require `proper-lockfile` or
|
|
31
|
+
* similar; omitted to keep the dependency surface minimal — the in-process
|
|
32
|
+
* case is the common path).
|
|
33
|
+
*
|
|
34
|
+
* ## Dedup
|
|
35
|
+
*
|
|
36
|
+
* `append` silently skips when an entry with the same `msg_id` is already
|
|
37
|
+
* buffered. The Rust `BufferQueue::push` does not dedup, but the disk queue
|
|
38
|
+
* MUST dedup to avoid sending the same `msg_id` twice to the relay on
|
|
39
|
+
* reconnect (relay-side ACKs would otherwise race).
|
|
40
|
+
*/
|
|
41
|
+
import * as fs from 'node:fs/promises';
|
|
42
|
+
import * as path from 'node:path';
|
|
43
|
+
import { encrypt, decrypt, KEY_VERSION } from '../crypto/aes-gcm.js';
|
|
44
|
+
/** Default cap — 50 MiB. Matches Rust `BufferConfig::default().max_memory_mb`. */
|
|
45
|
+
export const DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
|
|
46
|
+
/** Filename of the encrypted queue blob (relative to `dir`). */
|
|
47
|
+
export const QUEUE_FILE_NAME = 'buffer.queue.enc';
|
|
48
|
+
/** Filename of the atomic-write temp file (relative to `dir`). */
|
|
49
|
+
export const QUEUE_TMP_NAME = `.${QUEUE_FILE_NAME}.tmp`;
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Errors
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
/** Thrown by {@link DiskBufferQueue.append} when the cap would be exceeded. */
|
|
54
|
+
export class BufferFullError extends Error {
|
|
55
|
+
currentBytes;
|
|
56
|
+
maxBytes;
|
|
57
|
+
constructor(currentBytes, maxBytes) {
|
|
58
|
+
super(`Buffer full: ${currentBytes} bytes would exceed cap of ${maxBytes} bytes`);
|
|
59
|
+
this.name = 'BufferFullError';
|
|
60
|
+
this.currentBytes = currentBytes;
|
|
61
|
+
this.maxBytes = maxBytes;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// DiskBufferQueue
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
/**
|
|
68
|
+
* Single-file, AES-256-GCM-encrypted, FIFO buffer queue.
|
|
69
|
+
*
|
|
70
|
+
* Append-only on the hot path. `drain()` consumes every entry in FIFO order,
|
|
71
|
+
* hands each to a handler, and deletes the on-disk file when the handler
|
|
72
|
+
* completes successfully. `peek()` and `size()` are non-mutating.
|
|
73
|
+
*/
|
|
74
|
+
export class DiskBufferQueue {
|
|
75
|
+
dir;
|
|
76
|
+
filePath;
|
|
77
|
+
tmpPath;
|
|
78
|
+
key;
|
|
79
|
+
maxBytes;
|
|
80
|
+
/** In-memory cache. Loaded lazily on first op. */
|
|
81
|
+
entries = [];
|
|
82
|
+
loaded = false;
|
|
83
|
+
/** Promise-chain mutex for in-process serialization. */
|
|
84
|
+
mutex = Promise.resolve();
|
|
85
|
+
constructor(opts) {
|
|
86
|
+
if (!(opts.key instanceof Uint8Array)) {
|
|
87
|
+
throw new Error('DiskBufferQueue: key must be a Uint8Array');
|
|
88
|
+
}
|
|
89
|
+
if (opts.key.length !== 32) {
|
|
90
|
+
throw new Error(`DiskBufferQueue: key must be 32 bytes (AES-256), got ${opts.key.length}`);
|
|
91
|
+
}
|
|
92
|
+
if (typeof opts.dir !== 'string' || opts.dir.length === 0) {
|
|
93
|
+
throw new Error('DiskBufferQueue: dir must be a non-empty string');
|
|
94
|
+
}
|
|
95
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
96
|
+
if (!Number.isFinite(maxBytes) || maxBytes <= 0) {
|
|
97
|
+
throw new Error(`DiskBufferQueue: maxBytes must be a positive finite number, got ${maxBytes}`);
|
|
98
|
+
}
|
|
99
|
+
this.dir = opts.dir;
|
|
100
|
+
this.key = opts.key;
|
|
101
|
+
this.maxBytes = maxBytes;
|
|
102
|
+
this.filePath = path.join(this.dir, QUEUE_FILE_NAME);
|
|
103
|
+
this.tmpPath = path.join(this.dir, QUEUE_TMP_NAME);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Append an entry to the queue. Skips silently when `entry.msg_id` is
|
|
107
|
+
* already buffered (dedup invariant). Throws {@link BufferFullError} when
|
|
108
|
+
* the serialized envelope would exceed `maxBytes`.
|
|
109
|
+
*/
|
|
110
|
+
async append(entry) {
|
|
111
|
+
return this.withLock(async () => {
|
|
112
|
+
await this.ensureLoaded();
|
|
113
|
+
this.assertValidEntry(entry);
|
|
114
|
+
// Dedup: skip if msg_id already buffered.
|
|
115
|
+
if (this.entries.some((e) => e.msg_id === entry.msg_id)) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const projected = this.projectedBytesAfterAppend(entry);
|
|
119
|
+
if (projected > this.maxBytes) {
|
|
120
|
+
throw new BufferFullError(projected, this.maxBytes);
|
|
121
|
+
}
|
|
122
|
+
// Defensive copy of bytes — caller may mutate after the await.
|
|
123
|
+
this.entries.push({
|
|
124
|
+
msg_id: entry.msg_id,
|
|
125
|
+
timestamp: entry.timestamp,
|
|
126
|
+
bytes: new Uint8Array(entry.bytes),
|
|
127
|
+
});
|
|
128
|
+
await this.persist();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Return up to `limit` entries (FIFO order) without removing them.
|
|
133
|
+
* Defensive copy — callers may freely mutate the returned bytes.
|
|
134
|
+
*/
|
|
135
|
+
async peek(limit) {
|
|
136
|
+
return this.withLock(async () => {
|
|
137
|
+
await this.ensureLoaded();
|
|
138
|
+
if (!Number.isInteger(limit) || limit < 0) {
|
|
139
|
+
throw new Error(`DiskBufferQueue.peek: limit must be a non-negative integer, got ${limit}`);
|
|
140
|
+
}
|
|
141
|
+
return this.entries.slice(0, limit).map(cloneEntry);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** Return the number of entries currently buffered. */
|
|
145
|
+
async size() {
|
|
146
|
+
return this.withLock(async () => {
|
|
147
|
+
await this.ensureLoaded();
|
|
148
|
+
return this.entries.length;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Delete all entries and the on-disk file. Idempotent — succeeds whether
|
|
153
|
+
* the file exists or not.
|
|
154
|
+
*/
|
|
155
|
+
async clear() {
|
|
156
|
+
return this.withLock(async () => {
|
|
157
|
+
await this.ensureLoaded();
|
|
158
|
+
this.entries = [];
|
|
159
|
+
await this.unlinkIfExists(this.filePath);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Invoke `handler(entry)` for every entry in FIFO order, then delete the
|
|
164
|
+
* on-disk file. Returns the number of entries processed. If the handler
|
|
165
|
+
* throws, the entries remain on disk for a future `drain()` retry
|
|
166
|
+
* (at-least-once semantics — matches the Rust offline-queue contract).
|
|
167
|
+
*/
|
|
168
|
+
async drain(handler) {
|
|
169
|
+
return this.withLock(async () => {
|
|
170
|
+
await this.ensureLoaded();
|
|
171
|
+
let count = 0;
|
|
172
|
+
for (const entry of this.entries) {
|
|
173
|
+
await handler(cloneEntry(entry));
|
|
174
|
+
count += 1;
|
|
175
|
+
}
|
|
176
|
+
this.entries = [];
|
|
177
|
+
await this.unlinkIfExists(this.filePath);
|
|
178
|
+
return count;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// -------------------------------------------------------------------------
|
|
182
|
+
// Private: lazy init / crash recovery / persistence
|
|
183
|
+
// -------------------------------------------------------------------------
|
|
184
|
+
/**
|
|
185
|
+
* Lazily create the dir, recover from a prior crash (delete tmp), and
|
|
186
|
+
* load existing entries. Runs at most once per queue instance.
|
|
187
|
+
*/
|
|
188
|
+
async ensureLoaded() {
|
|
189
|
+
if (this.loaded) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
await fs.mkdir(this.dir, { recursive: true });
|
|
193
|
+
// Crash recovery: a leftover .tmp means the previous process died
|
|
194
|
+
// mid-write. The main file is either whole (atomic rename completed)
|
|
195
|
+
// or absent — the tmp can never have valid data on its own.
|
|
196
|
+
await this.unlinkIfExists(this.tmpPath);
|
|
197
|
+
// Load existing entries.
|
|
198
|
+
let raw;
|
|
199
|
+
try {
|
|
200
|
+
raw = await fs.readFile(this.filePath);
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
const code = err.code;
|
|
204
|
+
if (code === 'ENOENT') {
|
|
205
|
+
this.entries = [];
|
|
206
|
+
this.loaded = true;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
throw err;
|
|
210
|
+
}
|
|
211
|
+
const result = decrypt(raw, this.key);
|
|
212
|
+
this.entries = deserializeEntries(result.plaintext);
|
|
213
|
+
this.loaded = true;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Encrypt + atomic-write the in-memory entry list to disk.
|
|
217
|
+
*
|
|
218
|
+
* Sequence (mirrors Rust `atomic_write` in `disk_dump.rs:159-186`):
|
|
219
|
+
* 1. encrypt(plaintext) → `[version][nonce][ciphertext][tag]`
|
|
220
|
+
* 2. open tmp with mode 0600 (defense in depth — encryption protects
|
|
221
|
+
* contents either way)
|
|
222
|
+
* 3. write blob + fsync the file (force kernel buffers to disk)
|
|
223
|
+
* 4. rename tmp → final path (atomic on POSIX, atomic on Win since Node 14+)
|
|
224
|
+
*/
|
|
225
|
+
async persist() {
|
|
226
|
+
const plaintext = serializeEntries(this.entries);
|
|
227
|
+
const encrypted = encrypt(this.key, plaintext);
|
|
228
|
+
let handle;
|
|
229
|
+
try {
|
|
230
|
+
handle = await fs.open(this.tmpPath, 'w', 0o600);
|
|
231
|
+
await handle.writeFile(encrypted);
|
|
232
|
+
await handle.sync();
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
if (handle !== undefined) {
|
|
236
|
+
await handle.close();
|
|
237
|
+
handle = undefined;
|
|
238
|
+
}
|
|
239
|
+
// Best-effort tmp cleanup so a failed write doesn't leave junk.
|
|
240
|
+
await this.unlinkIfExists(this.tmpPath);
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
if (handle !== undefined) {
|
|
245
|
+
await handle.close();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
await fs.rename(this.tmpPath, this.filePath);
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
// Clean up tmp on rename failure to avoid leaving junk for next startup.
|
|
253
|
+
await this.unlinkIfExists(this.tmpPath);
|
|
254
|
+
throw err;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
projectedBytesAfterAppend(entry) {
|
|
258
|
+
return serializedByteLength(this.entries) + serializedEntryByteLength(entry);
|
|
259
|
+
}
|
|
260
|
+
/** Serialize operations via a Promise chain (in-process mutex). */
|
|
261
|
+
withLock(fn) {
|
|
262
|
+
// Run `fn` after the previous operation settles (success OR failure —
|
|
263
|
+
// we never want a rejected op to poison the lock for future calls).
|
|
264
|
+
const run = this.mutex.then(fn, fn);
|
|
265
|
+
this.mutex = run.then(() => undefined, () => undefined);
|
|
266
|
+
return run;
|
|
267
|
+
}
|
|
268
|
+
assertValidEntry(entry) {
|
|
269
|
+
if (typeof entry.msg_id !== 'string' || entry.msg_id.length === 0) {
|
|
270
|
+
throw new Error('DiskBufferQueue.append: entry.msg_id must be a non-empty string');
|
|
271
|
+
}
|
|
272
|
+
if (typeof entry.timestamp !== 'number' || !Number.isFinite(entry.timestamp)) {
|
|
273
|
+
throw new Error('DiskBufferQueue.append: entry.timestamp must be a finite number');
|
|
274
|
+
}
|
|
275
|
+
if (!(entry.bytes instanceof Uint8Array)) {
|
|
276
|
+
throw new Error('DiskBufferQueue.append: entry.bytes must be a Uint8Array');
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
async unlinkIfExists(target) {
|
|
280
|
+
try {
|
|
281
|
+
await fs.unlink(target);
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
const code = err.code;
|
|
285
|
+
if (code !== 'ENOENT') {
|
|
286
|
+
throw err;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// Serialization helpers
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
/**
|
|
295
|
+
* Serialize all entries to a compact JSON string. `bytes` is base64-encoded
|
|
296
|
+
* so the result is pure ASCII (avoids `\uXXXX` expansion of arbitrary Uint8
|
|
297
|
+
* payloads) and round-trips losslessly.
|
|
298
|
+
*/
|
|
299
|
+
function serializeEntries(entries) {
|
|
300
|
+
const wire = entries.map((e) => ({
|
|
301
|
+
msg_id: e.msg_id,
|
|
302
|
+
timestamp: e.timestamp,
|
|
303
|
+
bytes: Buffer.from(e.bytes).toString('base64'),
|
|
304
|
+
}));
|
|
305
|
+
return JSON.stringify(wire);
|
|
306
|
+
}
|
|
307
|
+
/** Inverse of {@link serializeEntries}. Throws on malformed data. */
|
|
308
|
+
function deserializeEntries(json) {
|
|
309
|
+
let parsed;
|
|
310
|
+
try {
|
|
311
|
+
parsed = JSON.parse(json);
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
throw new Error(`DiskBufferQueue: queue file contains invalid JSON: ${err.message}`);
|
|
315
|
+
}
|
|
316
|
+
if (!Array.isArray(parsed)) {
|
|
317
|
+
throw new Error('DiskBufferQueue: queue file root must be an array');
|
|
318
|
+
}
|
|
319
|
+
return parsed.map((raw, idx) => parseEntry(raw, idx));
|
|
320
|
+
}
|
|
321
|
+
function parseEntry(raw, idx) {
|
|
322
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
323
|
+
throw new Error(`DiskBufferQueue: entry ${idx} is not an object`);
|
|
324
|
+
}
|
|
325
|
+
const rec = raw;
|
|
326
|
+
if (typeof rec['msg_id'] !== 'string' || rec['msg_id'].length === 0) {
|
|
327
|
+
throw new Error(`DiskBufferQueue: entry ${idx} has invalid msg_id`);
|
|
328
|
+
}
|
|
329
|
+
if (typeof rec['timestamp'] !== 'number' ||
|
|
330
|
+
!Number.isFinite(rec['timestamp'])) {
|
|
331
|
+
throw new Error(`DiskBufferQueue: entry ${idx} has invalid timestamp`);
|
|
332
|
+
}
|
|
333
|
+
if (typeof rec['bytes'] !== 'string') {
|
|
334
|
+
throw new Error(`DiskBufferQueue: entry ${idx} has invalid bytes (expected base64 string)`);
|
|
335
|
+
}
|
|
336
|
+
// Base64 of any byte sequence is always valid base64; Buffer.from will
|
|
337
|
+
// silently truncate if the string is malformed, so we accept the decoded
|
|
338
|
+
// length without a strict validator here.
|
|
339
|
+
const buf = Buffer.from(rec['bytes'], 'base64');
|
|
340
|
+
return {
|
|
341
|
+
msg_id: rec['msg_id'],
|
|
342
|
+
timestamp: rec['timestamp'],
|
|
343
|
+
bytes: new Uint8Array(buf),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/** Byte length of an entry as it would appear in the JSON envelope. */
|
|
347
|
+
function serializedEntryByteLength(entry) {
|
|
348
|
+
return Buffer.byteLength(JSON.stringify({
|
|
349
|
+
msg_id: entry.msg_id,
|
|
350
|
+
timestamp: entry.timestamp,
|
|
351
|
+
bytes: Buffer.from(entry.bytes).toString('base64'),
|
|
352
|
+
}), 'utf8');
|
|
353
|
+
}
|
|
354
|
+
function serializedByteLength(entries) {
|
|
355
|
+
let total = 0;
|
|
356
|
+
for (const e of entries) {
|
|
357
|
+
total += serializedEntryByteLength(e);
|
|
358
|
+
}
|
|
359
|
+
return total;
|
|
360
|
+
}
|
|
361
|
+
function cloneEntry(entry) {
|
|
362
|
+
return {
|
|
363
|
+
msg_id: entry.msg_id,
|
|
364
|
+
timestamp: entry.timestamp,
|
|
365
|
+
bytes: new Uint8Array(entry.bytes),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
// Re-export the on-disk format version so tests + ops tools can verify it
|
|
369
|
+
// without re-importing from the crypto module.
|
|
370
|
+
export { KEY_VERSION };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function listDevices(limit: number): Promise<number>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `devices` subcommand — list devices owned by current operator.
|
|
3
|
+
* Mirrors `edge-client/src/cli/devices.rs`.
|
|
4
|
+
*/
|
|
5
|
+
import { loadSettings } from '../config/settings.js';
|
|
6
|
+
import { loadDeviceSecret } from '../config/settings.js';
|
|
7
|
+
export async function listDevices(limit) {
|
|
8
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
9
|
+
console.error('ec-ts devices: invalid --limit');
|
|
10
|
+
return 1;
|
|
11
|
+
}
|
|
12
|
+
const settings = loadSettings();
|
|
13
|
+
if (!settings.owner_operator_id) {
|
|
14
|
+
console.error('ec-ts devices: not enrolled (run `ec-ts enroll --key <KEY>` first)');
|
|
15
|
+
return 2;
|
|
16
|
+
}
|
|
17
|
+
const secret = loadDeviceSecret();
|
|
18
|
+
if (!secret) {
|
|
19
|
+
console.error('ec-ts devices: device secret missing');
|
|
20
|
+
return 3;
|
|
21
|
+
}
|
|
22
|
+
const httpUrl = settings.server.ws_url
|
|
23
|
+
.replace(/^wss:/, 'https:')
|
|
24
|
+
.replace(/^ws:/, 'http:')
|
|
25
|
+
.replace(/\/edge$/, '')
|
|
26
|
+
.replace(/\/$/, '');
|
|
27
|
+
const url = `${httpUrl}/api/devices?operator_id=${encodeURIComponent(settings.owner_operator_id)}&limit=${limit}`;
|
|
28
|
+
let resp;
|
|
29
|
+
try {
|
|
30
|
+
resp = await fetch(url, {
|
|
31
|
+
headers: { 'X-Device-Secret': secret },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
console.error(`ec-ts devices: network error: ${String(e)}`);
|
|
36
|
+
return 4;
|
|
37
|
+
}
|
|
38
|
+
if (!resp.ok) {
|
|
39
|
+
console.error(`ec-ts devices: HTTP ${resp.status}`);
|
|
40
|
+
return 5;
|
|
41
|
+
}
|
|
42
|
+
let body;
|
|
43
|
+
try {
|
|
44
|
+
body = (await resp.json());
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
console.error('ec-ts devices: non-JSON response');
|
|
48
|
+
return 6;
|
|
49
|
+
}
|
|
50
|
+
const items = body.items ?? [];
|
|
51
|
+
if (items.length === 0) {
|
|
52
|
+
console.log('(no devices)');
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
console.log(`Devices (${items.length}):`);
|
|
56
|
+
for (const d of items) {
|
|
57
|
+
const last = d.last_seen_ms ? new Date(d.last_seen_ms).toISOString() : 'never';
|
|
58
|
+
console.log(` ${d.id}\t${d.display_name ?? d.machine_id}\t${d.status ?? '?'}\tlast-seen: ${last}`);
|
|
59
|
+
}
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `enroll` subcommand — bind this edge node to an operator account.
|
|
3
|
+
* Mirrors `edge-client/src/cli/enroll.rs`.
|
|
4
|
+
*
|
|
5
|
+
* Validates the operator API key against the relay's enroll endpoint
|
|
6
|
+
* (HTTPS POST to `<ws_url>/api/devices/enroll` rewritten to HTTP) and on
|
|
7
|
+
* success writes `machine_id`, `ws_url`, `owner_operator_id`, `default_agent_id`,
|
|
8
|
+
* and the encrypted secrets file.
|
|
9
|
+
*/
|
|
10
|
+
import { defaultSettings, saveSettings, storeDeviceSecret, loadSettings, } from '../config/settings.js';
|
|
11
|
+
import { generateMachineId } from '../config/settings.js';
|
|
12
|
+
/** Validate the API key with the relay and persist settings + secrets. */
|
|
13
|
+
export async function enroll(apiKey) {
|
|
14
|
+
if (!apiKey || typeof apiKey !== 'string' || apiKey.length < 8) {
|
|
15
|
+
console.error('ec-ts enroll: invalid API key (must be at least 8 chars)');
|
|
16
|
+
return 1;
|
|
17
|
+
}
|
|
18
|
+
// 1. Determine the relay URL — try existing settings first, else fall back to a sane default.
|
|
19
|
+
let settings;
|
|
20
|
+
try {
|
|
21
|
+
settings = loadSettings();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
settings = defaultSettings();
|
|
25
|
+
}
|
|
26
|
+
const relayUrl = settings.server.ws_url;
|
|
27
|
+
const enrollUrl = relayUrl
|
|
28
|
+
.replace(/^wss:/, 'https:')
|
|
29
|
+
.replace(/^ws:/, 'http:')
|
|
30
|
+
.replace(/\/edge$/, '')
|
|
31
|
+
.replace(/\/$/, '') + '/api/devices/enroll';
|
|
32
|
+
// 2. POST to the relay to validate the key.
|
|
33
|
+
let resp;
|
|
34
|
+
try {
|
|
35
|
+
resp = await fetch(enrollUrl, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: {
|
|
38
|
+
'Content-Type': 'application/json',
|
|
39
|
+
'X-Operator-Key': apiKey,
|
|
40
|
+
},
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
machine_id: settings.machine_id ?? generateMachineId(),
|
|
43
|
+
agent_id: settings.agent.default_agent_id,
|
|
44
|
+
platform: process.platform,
|
|
45
|
+
hostname: (await import('node:os')).hostname(),
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
console.error(`ec-ts enroll: network error: ${String(e)}`);
|
|
51
|
+
return 2;
|
|
52
|
+
}
|
|
53
|
+
if (!resp.ok) {
|
|
54
|
+
const text = await resp.text().catch(() => '');
|
|
55
|
+
console.error(`ec-ts enroll: relay rejected key (HTTP ${resp.status}): ${text.slice(0, 200)}`);
|
|
56
|
+
return 3;
|
|
57
|
+
}
|
|
58
|
+
// 3. Parse response: `{ operator_id, agent_id, machine_id }`.
|
|
59
|
+
let body;
|
|
60
|
+
try {
|
|
61
|
+
body = (await resp.json());
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
console.error('ec-ts enroll: relay returned non-JSON');
|
|
65
|
+
return 4;
|
|
66
|
+
}
|
|
67
|
+
const operatorId = body.operator_id;
|
|
68
|
+
if (!operatorId) {
|
|
69
|
+
console.error('ec-ts enroll: relay response missing operator_id');
|
|
70
|
+
return 5;
|
|
71
|
+
}
|
|
72
|
+
// 4. Persist settings + secrets. NEVER log the api key.
|
|
73
|
+
settings.owner_operator_id = operatorId;
|
|
74
|
+
settings.agent.default_agent_id = body.agent_id ?? settings.agent.default_agent_id;
|
|
75
|
+
settings.machine_id = body.machine_id ?? settings.machine_id ?? generateMachineId();
|
|
76
|
+
try {
|
|
77
|
+
saveSettings(settings);
|
|
78
|
+
storeDeviceSecret(apiKey);
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
console.error(`ec-ts enroll: failed to persist settings: ${String(e)}`);
|
|
82
|
+
return 6;
|
|
83
|
+
}
|
|
84
|
+
console.log(`ec-ts: enrolled as operator ${operatorId}`);
|
|
85
|
+
console.log(` machine_id: ${settings.machine_id}`);
|
|
86
|
+
console.log(` agent_id: ${settings.agent.default_agent_id}`);
|
|
87
|
+
console.log(` relay: ${settings.server.ws_url}`);
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface DispatchResult {
|
|
2
|
+
exitCode: number;
|
|
3
|
+
stdout?: string;
|
|
4
|
+
stderr?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Parse argv and dispatch to the appropriate subcommand.
|
|
8
|
+
* Returns the process exit code. When no subcommand is given, defaults to `run`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function dispatch(argv: string[]): Promise<number>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI dispatch — mirrors `edge-client/src/cli/mod.rs`.
|
|
3
|
+
*
|
|
4
|
+
* Subcommands:
|
|
5
|
+
* - run (default) start the daemon
|
|
6
|
+
* - enroll bind this edge node to an operator account
|
|
7
|
+
* - whoami show local identity (operator_id, machine_id, ws_url)
|
|
8
|
+
* - status show daemon + relay contact status
|
|
9
|
+
* - devices list devices owned by current operator
|
|
10
|
+
* - messages list recent LLM conversation messages (operator-scoped)
|
|
11
|
+
*
|
|
12
|
+
* All control commands load `Settings` from disk via `loadSettings()` and call
|
|
13
|
+
* the relay over HTTP fetch. The `run` subcommand starts the long-running
|
|
14
|
+
* daemon (WS connect, session manager, buffer queue, heartbeat).
|
|
15
|
+
*/
|
|
16
|
+
import { Command } from 'commander';
|
|
17
|
+
import { runDaemon } from './run.js';
|
|
18
|
+
import { enroll } from './enroll.js';
|
|
19
|
+
import { whoami } from './whoami.js';
|
|
20
|
+
import { status } from './status.js';
|
|
21
|
+
import { listDevices } from './devices.js';
|
|
22
|
+
import { listMessages } from './messages.js';
|
|
23
|
+
/**
|
|
24
|
+
* Parse argv and dispatch to the appropriate subcommand.
|
|
25
|
+
* Returns the process exit code. When no subcommand is given, defaults to `run`.
|
|
26
|
+
*/
|
|
27
|
+
export async function dispatch(argv) {
|
|
28
|
+
const program = new Command();
|
|
29
|
+
// exitOverride() makes commander throw a CommanderError instead of calling
|
|
30
|
+
// process.exit(0/1) on --version / --help / parse errors. This keeps the
|
|
31
|
+
// dispatcher testable and embeddable (no silent process termination).
|
|
32
|
+
program.exitOverride();
|
|
33
|
+
program
|
|
34
|
+
.name('ec-ts')
|
|
35
|
+
.description('Edge-client TypeScript (Model 1) — Edge AI Agent fleet daemon')
|
|
36
|
+
.version('7.0.0');
|
|
37
|
+
program
|
|
38
|
+
.command('run')
|
|
39
|
+
.description('Start the edge-client daemon (default if no subcommand)')
|
|
40
|
+
.action(async () => {
|
|
41
|
+
const code = await runDaemon();
|
|
42
|
+
process.exitCode = code;
|
|
43
|
+
});
|
|
44
|
+
program
|
|
45
|
+
.command('enroll')
|
|
46
|
+
.description('Bind this edge node to an operator account by pasting the per-operator API key')
|
|
47
|
+
.requiredOption('-k, --key <key>', 'operator API key (paste from dashboard)')
|
|
48
|
+
.action(async (opts) => {
|
|
49
|
+
const code = await enroll(opts.key);
|
|
50
|
+
process.exitCode = code;
|
|
51
|
+
});
|
|
52
|
+
program
|
|
53
|
+
.command('whoami')
|
|
54
|
+
.description('Show this edge node\'s local identity (no network calls)')
|
|
55
|
+
.action(async () => {
|
|
56
|
+
const code = await whoami();
|
|
57
|
+
process.exitCode = code;
|
|
58
|
+
});
|
|
59
|
+
program
|
|
60
|
+
.command('status')
|
|
61
|
+
.description('Show daemon + relay contact status')
|
|
62
|
+
.action(async () => {
|
|
63
|
+
const code = await status();
|
|
64
|
+
process.exitCode = code;
|
|
65
|
+
});
|
|
66
|
+
program
|
|
67
|
+
.command('devices')
|
|
68
|
+
.description('List devices owned by current operator')
|
|
69
|
+
.option('-l, --limit <n>', 'max devices to list', '50')
|
|
70
|
+
.action(async (opts) => {
|
|
71
|
+
const code = await listDevices(Number(opts.limit));
|
|
72
|
+
process.exitCode = code;
|
|
73
|
+
});
|
|
74
|
+
program
|
|
75
|
+
.command('messages')
|
|
76
|
+
.description('List recent LLM conversation messages (operator-scoped)')
|
|
77
|
+
.option('-l, --limit <n>', 'max messages to list', '50')
|
|
78
|
+
.action(async (opts) => {
|
|
79
|
+
const code = await listMessages(Number(opts.limit));
|
|
80
|
+
process.exitCode = code;
|
|
81
|
+
});
|
|
82
|
+
try {
|
|
83
|
+
// Commander expects argv in `[execPath, scriptPath, ...args]` form (mirrors process.argv).
|
|
84
|
+
// Callers like tests often pass just `[...args]`, so normalize here.
|
|
85
|
+
const normalizedArgv = argv.length >= 2 && typeof argv[0] === 'string' && (argv[0].startsWith('/') || argv[0].includes('\\') || argv[0] === 'node')
|
|
86
|
+
? argv
|
|
87
|
+
: ['node', 'ec-ts', ...argv];
|
|
88
|
+
await program.parseAsync(normalizedArgv);
|
|
89
|
+
// process.exitCode can be string|number|undefined (signals); coerce to number for the contract.
|
|
90
|
+
const ec = process.exitCode;
|
|
91
|
+
return typeof ec === 'number' ? ec : 0;
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
// Commander throws CommanderError for --version / --help / unknown commands.
|
|
95
|
+
// Map to a clean exit code per CLI convention.
|
|
96
|
+
const err = e;
|
|
97
|
+
// Commander throws CommanderError for --help / --version / unknown commands.
|
|
98
|
+
// Help/version should always map to exit code 0 (it's an informational display,
|
|
99
|
+
// not an error), regardless of what exitCode commander attached to the error.
|
|
100
|
+
const HELP_LIKE_CODES = new Set([
|
|
101
|
+
'commander.help',
|
|
102
|
+
'commander.helpDisplayed',
|
|
103
|
+
'commander.version',
|
|
104
|
+
'commander.versionDisplayed',
|
|
105
|
+
]);
|
|
106
|
+
if (err && typeof err.code === 'string' && HELP_LIKE_CODES.has(err.code)) {
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
if (err && typeof err.code === 'string' && err.code.startsWith('commander.')) {
|
|
110
|
+
// Other commander errors (unknownCommand, missingArgument, etc.) → exit 1.
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
console.error('ec-ts: unexpected error:', e);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function listMessages(limit: number): Promise<number>;
|