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.
Files changed (57) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/LICENSE +21 -0
  3. package/README.md +174 -0
  4. package/bin/ec-ts.js +18 -0
  5. package/dist/buffer/disk-queue.d.ts +140 -0
  6. package/dist/buffer/disk-queue.js +370 -0
  7. package/dist/cli/devices.d.ts +1 -0
  8. package/dist/cli/devices.js +61 -0
  9. package/dist/cli/enroll.d.ts +2 -0
  10. package/dist/cli/enroll.js +89 -0
  11. package/dist/cli/index.d.ts +10 -0
  12. package/dist/cli/index.js +116 -0
  13. package/dist/cli/messages.d.ts +1 -0
  14. package/dist/cli/messages.js +59 -0
  15. package/dist/cli/run.d.ts +5 -0
  16. package/dist/cli/run.js +112 -0
  17. package/dist/cli/status.d.ts +1 -0
  18. package/dist/cli/status.js +56 -0
  19. package/dist/cli/whoami.d.ts +2 -0
  20. package/dist/cli/whoami.js +41 -0
  21. package/dist/config/cmd-gate.d.ts +65 -0
  22. package/dist/config/cmd-gate.js +128 -0
  23. package/dist/config/settings.d.ts +209 -0
  24. package/dist/config/settings.js +627 -0
  25. package/dist/crypto/aes-gcm.d.ts +38 -0
  26. package/dist/crypto/aes-gcm.js +90 -0
  27. package/dist/crypto/hmac.d.ts +31 -0
  28. package/dist/crypto/hmac.js +52 -0
  29. package/dist/crypto/tls-guard.d.ts +36 -0
  30. package/dist/crypto/tls-guard.js +54 -0
  31. package/dist/daemon/manager.d.ts +82 -0
  32. package/dist/daemon/manager.js +461 -0
  33. package/dist/index.d.ts +39 -0
  34. package/dist/index.js +63 -0
  35. package/dist/logging/file-logger.d.ts +21 -0
  36. package/dist/logging/file-logger.js +71 -0
  37. package/dist/network/ws-client.d.ts +221 -0
  38. package/dist/network/ws-client.js +1134 -0
  39. package/dist/session/fail-fast.d.ts +70 -0
  40. package/dist/session/fail-fast.js +122 -0
  41. package/dist/session/manager.d.ts +136 -0
  42. package/dist/session/manager.js +291 -0
  43. package/dist/session/persistence.d.ts +103 -0
  44. package/dist/session/persistence.js +194 -0
  45. package/dist/session/pi-rpc.d.ts +164 -0
  46. package/dist/session/pi-rpc.js +412 -0
  47. package/dist/session/sftp.d.ts +64 -0
  48. package/dist/session/sftp.js +335 -0
  49. package/dist/session/shell-frame.d.ts +77 -0
  50. package/dist/session/shell-frame.js +199 -0
  51. package/dist/session/shell.d.ts +124 -0
  52. package/dist/session/shell.js +300 -0
  53. package/docs/CONFIGURATION.md +169 -0
  54. package/docs/INSTALLATION.md +164 -0
  55. package/docs/PROTOCOL.md +248 -0
  56. package/docs/TROUBLESHOOTING.md +177 -0
  57. 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,2 @@
1
+ /** Validate the API key with the relay and persist settings + secrets. */
2
+ export declare function enroll(apiKey: string): Promise<number>;
@@ -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>;