@babylen/legion 0.1.1 → 0.1.3

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 (2) hide show
  1. package/dist/index.js +327 -79
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -23,11 +23,15 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  mod
24
24
  ));
25
25
 
26
+ // src/index.ts
27
+ var import_child_process = require("child_process");
28
+
29
+ // package.json
30
+ var version = "0.1.3";
31
+
26
32
  // src/index.ts
27
33
  var import_socket = require("socket.io-client");
28
- var os3 = __toESM(require("os"));
29
- var import_fs2 = require("fs");
30
- var import_path4 = require("path");
34
+ var os4 = __toESM(require("os"));
31
35
 
32
36
  // src/core/config.ts
33
37
  var import_os = __toESM(require("os"));
@@ -44,6 +48,14 @@ var ConfigSchema = import_zod.z.object({
44
48
  var HOME_DIR = import_os.default.homedir();
45
49
  var CONFIG_DIR = import_path.default.join(HOME_DIR, ".tanuki");
46
50
  var CONFIG_FILE = import_path.default.join(CONFIG_DIR, "config.json");
51
+ async function ensureConfigDir() {
52
+ try {
53
+ await import_promises.default.access(CONFIG_DIR);
54
+ } catch {
55
+ await import_promises.default.mkdir(CONFIG_DIR, { recursive: true });
56
+ }
57
+ return CONFIG_FILE;
58
+ }
47
59
  async function loadConfig() {
48
60
  try {
49
61
  const content = await import_promises.default.readFile(CONFIG_FILE, "utf-8");
@@ -53,6 +65,14 @@ async function loadConfig() {
53
65
  return null;
54
66
  }
55
67
  }
68
+ async function saveConfig(config2) {
69
+ await ensureConfigDir();
70
+ await import_promises.default.writeFile(CONFIG_FILE, JSON.stringify(config2, null, 2), "utf-8");
71
+ try {
72
+ await import_promises.default.chmod(CONFIG_FILE, 384);
73
+ } catch {
74
+ }
75
+ }
56
76
  async function getConfig() {
57
77
  const fileConfig = await loadConfig();
58
78
  const id = fileConfig?.id || process.env.LEGION_TOKEN_ID || void 0;
@@ -72,6 +92,18 @@ async function getConfig() {
72
92
  }
73
93
  return config2;
74
94
  }
95
+ async function updateConfig(updates) {
96
+ const currentConfig = await loadConfig();
97
+ if (!currentConfig) {
98
+ throw new Error("Cannot update config: config file does not exist");
99
+ }
100
+ const updatedConfig = {
101
+ ...currentConfig,
102
+ ...updates
103
+ };
104
+ ConfigSchema.parse(updatedConfig);
105
+ await saveConfig(updatedConfig);
106
+ }
75
107
 
76
108
  // src/util/log.ts
77
109
  var import_path3 = __toESM(require("path"));
@@ -260,104 +292,320 @@ var Log;
260
292
  Log2.create = create;
261
293
  })(Log || (Log = {}));
262
294
 
295
+ // src/util/fingerprint.ts
296
+ var os3 = __toESM(require("os"));
297
+ var cachedFingerprint = null;
298
+ async function getSystemFingerprint() {
299
+ if (cachedFingerprint) return cachedFingerprint;
300
+ const hostname3 = os3.hostname();
301
+ const platform3 = os3.platform();
302
+ const arch2 = os3.arch();
303
+ const cpuModel = os3.cpus()[0]?.model || "unknown";
304
+ const interfaces = os3.networkInterfaces();
305
+ let macAddress = "";
306
+ for (const iface of Object.values(interfaces || {})) {
307
+ for (const addr of iface || []) {
308
+ if (!addr.internal && addr.mac && addr.mac !== "00:00:00:00:00:00") {
309
+ macAddress = addr.mac;
310
+ break;
311
+ }
312
+ }
313
+ if (macAddress) break;
314
+ }
315
+ const data2 = JSON.stringify({ hostname: hostname3, platform: platform3, arch: arch2, cpuModel, macAddress });
316
+ const encoder = new TextEncoder();
317
+ const dataBuffer = encoder.encode(data2);
318
+ const hashBuffer = await crypto.subtle.digest("SHA-256", dataBuffer);
319
+ cachedFingerprint = Buffer.from(hashBuffer).toString("hex");
320
+ return cachedFingerprint;
321
+ }
322
+
263
323
  // src/index.ts
264
- var packageJsonPath = (0, import_path4.join)(__dirname, "..", "package.json");
265
- var version = "0.0.0";
266
- try {
267
- const packageJson = JSON.parse((0, import_fs2.readFileSync)(packageJsonPath, "utf-8"));
268
- version = packageJson.version;
269
- } catch {
270
- version = "0.0.0";
324
+ if (process.argv.includes("--version") || process.argv.includes("-v")) {
325
+ console.log(version);
326
+ process.exit(0);
271
327
  }
272
- async function main() {
273
- if (process.argv.includes("--version") || process.argv.includes("-v")) {
274
- console.log(version);
275
- process.exit(0);
328
+ function parseArgs() {
329
+ const args = process.argv.slice(2);
330
+ const parsed = {};
331
+ for (let i = 0; i < args.length; i++) {
332
+ if (args[i] === "--token" && args[i + 1]) {
333
+ parsed.token = args[i + 1];
334
+ i++;
335
+ } else if (args[i] === "auth" && args[i + 1] === "--token" && args[i + 2]) {
336
+ parsed.command = "auth";
337
+ parsed.token = args[i + 2];
338
+ i += 2;
339
+ } else if (args[i] === "login") {
340
+ parsed.command = "login";
341
+ } else if (args[i] === "--version" || args[i] === "-v") {
342
+ parsed.version = true;
343
+ }
276
344
  }
277
- try {
278
- const log2 = Log.create({ service: "legion" });
279
- await Global.init();
280
- log2.info(`\u{1F6E1}\uFE0F Legion v${version} starting...`);
281
- const config2 = await getConfig();
282
- log2.info("\u{1F517} Connecting to server", { serverUrl: config2.serverUrl });
283
- const socket = (0, import_socket.io)(config2.serverUrl, {
284
- auth: {
285
- id: config2.id,
286
- // Token ID for faster lookup (optional)
287
- token: config2.token,
288
- // Secret token for authentication
289
- type: "legion"
290
- },
291
- transports: ["websocket"],
292
- reconnection: true,
293
- reconnectionDelay: 1e3,
294
- reconnectionDelayMax: 5e3,
295
- reconnectionAttempts: Infinity
345
+ return parsed;
346
+ }
347
+ async function handleDeviceLogin(log, serverUrl = "https://tanuki.sabw.ru") {
348
+ const codeEndpoint = `${serverUrl}/api/v1/legion/device/code`;
349
+ const tokenEndpoint = `${serverUrl}/api/v1/legion/device/token`;
350
+ const codeResponse = await fetch(codeEndpoint, {
351
+ method: "POST",
352
+ headers: { "Content-Type": "application/json" }
353
+ });
354
+ if (!codeResponse.ok) {
355
+ throw new Error(`Failed to get device code: ${codeResponse.statusText}`);
356
+ }
357
+ const { code, activation_url } = await codeResponse.json();
358
+ console.log(`\u{1F449} Go to: ${activation_url || "https://tanuki.sabw.ru/activate"}`);
359
+ console.log(`\u{1F511} Enter code: ${code}`);
360
+ const pollInterval = 3e3;
361
+ const maxAttempts = 60;
362
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
363
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
364
+ const tokenResponse = await fetch(tokenEndpoint, {
365
+ method: "POST",
366
+ headers: { "Content-Type": "application/json" },
367
+ body: JSON.stringify({ code })
368
+ });
369
+ if (tokenResponse.ok) {
370
+ const { token, token_id } = await tokenResponse.json();
371
+ const existingConfig = await loadConfig();
372
+ await saveConfig({
373
+ token,
374
+ id: token_id,
375
+ serverUrl: existingConfig?.serverUrl || "wss://tanuki.sabw.ru"
376
+ });
377
+ log.info("\u2705 Device activated successfully");
378
+ return;
379
+ } else if (tokenResponse.status === 202) {
380
+ continue;
381
+ } else {
382
+ throw new Error(`Failed to get token: ${tokenResponse.statusText}`);
383
+ }
384
+ }
385
+ throw new Error("Device activation timed out");
386
+ }
387
+ function checkForUpdates() {
388
+ setTimeout(async () => {
389
+ try {
390
+ const packageName = "@babylen/legion";
391
+ const command = `npm view ${packageName} version`;
392
+ const timeout = 3e3;
393
+ const latestVersion = (0, import_child_process.execSync)(command, {
394
+ encoding: "utf-8",
395
+ timeout,
396
+ stdio: ["ignore", "pipe", "ignore"]
397
+ }).trim();
398
+ if (latestVersion !== version) {
399
+ console.warn(`
400
+ \u26A0\uFE0F Update available: ${version} \u2192 ${latestVersion}`);
401
+ console.warn(` Run: npm install -g ${packageName}@latest
402
+ `);
403
+ }
404
+ } catch (error) {
405
+ }
406
+ }, 100);
407
+ }
408
+ async function enterHibernationMode(log, configFile, onConfigChanged) {
409
+ log.warn("\u26D4 Auth failed. Entering hibernation mode. Waiting for config update...");
410
+ const fs4 = await import("fs");
411
+ return new Promise((resolve) => {
412
+ const watchHandle = fs4.watch(configFile, async (eventType) => {
413
+ if (eventType === "change") {
414
+ log.info("\u{1F4DD} Config file changed. Attempting reconnection...");
415
+ try {
416
+ await new Promise((resolve2) => setTimeout(resolve2, 100));
417
+ await onConfigChanged();
418
+ watchHandle.close();
419
+ resolve();
420
+ } catch (error) {
421
+ log.error("\u274C Reconnection failed", { error });
422
+ }
423
+ }
296
424
  });
297
- socket.on("connect", () => {
298
- log2.info("\u2705 Connected to server", { socketId: socket.id });
299
- const handshakeData = {
300
- hostname: os3.hostname(),
301
- platform: os3.platform(),
302
- release: os3.release(),
303
- cwd: process.cwd()
304
- };
305
- socket.emit("legion:handshake", handshakeData);
306
- log2.debug("\u{1F4E4} Sent handshake", handshakeData);
425
+ });
426
+ }
427
+ function createSocket(config2) {
428
+ return (0, import_socket.io)(config2.serverUrl, {
429
+ auth: {
430
+ id: config2.id,
431
+ // Token ID for faster lookup (optional)
432
+ token: config2.token,
433
+ // Secret token for authentication
434
+ type: "legion"
435
+ },
436
+ transports: ["websocket"],
437
+ reconnection: true,
438
+ reconnectionDelay: 1e3,
439
+ reconnectionDelayMax: 5e3,
440
+ reconnectionAttempts: Infinity
441
+ });
442
+ }
443
+ function setupSocketHandlers(socket, log, fingerprint, version2, onTokenRotation, onReconnect) {
444
+ socket.on("connect", () => {
445
+ log.info("\u2705 Connected to server", { socketId: socket.id });
446
+ const handshakeData = {
447
+ fingerprint,
448
+ version: version2,
449
+ hostname: os4.hostname(),
450
+ platform: os4.platform(),
451
+ release: os4.release(),
452
+ cwd: process.cwd()
453
+ };
454
+ socket.emit("legion:handshake", handshakeData);
455
+ log.debug("\u{1F4E4} Sent handshake", handshakeData);
456
+ });
457
+ socket.on("connect_error", (err) => {
458
+ log.error("\u274C Connection error", {
459
+ message: err.message,
460
+ type: err.type
307
461
  });
308
- socket.on("connect_error", (err) => {
309
- log2.error("\u274C Connection error", {
310
- message: err.message,
311
- type: err.type
462
+ if (err.message.includes("auth") || err.message.includes("token")) {
463
+ log.error("\u{1F510} Authentication failed. Please check your token.");
464
+ log.error("\u{1F4A1} Re-authenticate by updating ~/.tanuki/config.json or LEGION_TOKEN env var");
465
+ enterHibernationMode(log, CONFIG_FILE, onReconnect).catch((error) => {
466
+ log.error("\u274C Hibernation mode failed", { error });
467
+ process.exit(1);
468
+ });
469
+ }
470
+ });
471
+ socket.on("disconnect", (reason) => {
472
+ log.warn("\u26A0\uFE0F Disconnected from server", { reason });
473
+ if (reason === "io server disconnect") {
474
+ log.error("\u{1F6AB} Server disconnected this client. Please check your token.");
475
+ enterHibernationMode(log, CONFIG_FILE, onReconnect).catch((error) => {
476
+ log.error("\u274C Hibernation mode failed", { error });
477
+ process.exit(1);
312
478
  });
313
- if (err.message.includes("auth") || err.message.includes("token")) {
314
- log2.error("\u{1F510} Authentication failed. Please check your token.");
315
- log2.error("\u{1F4A1} Re-authenticate by updating ~/.tanuki/config.json or LEGION_TOKEN env var");
479
+ }
480
+ });
481
+ socket.on("legion:update_token", async (data2) => {
482
+ try {
483
+ log.info("\u{1F504} Received permanent credentials. Persisting...");
484
+ await updateConfig({
485
+ token: data2.secret,
486
+ // secret -> token
487
+ id: data2.token_id
488
+ // token_id -> id
489
+ });
490
+ log.info("\u2705 Config updated successfully. Reconnecting with new token...");
491
+ await onTokenRotation();
492
+ } catch (error) {
493
+ log.error("\u274C Failed to update config", { error });
494
+ }
495
+ });
496
+ socket.on("server:ping", (data2) => {
497
+ log.debug("\u{1F4E9} Ping from server", data2);
498
+ socket.emit("legion:pong", { ts: Date.now() });
499
+ });
500
+ socket.on("reconnect", (attemptNumber) => {
501
+ log.info("\u{1F504} Reconnected to server", { attempt: attemptNumber });
502
+ });
503
+ socket.on("reconnect_attempt", (attemptNumber) => {
504
+ log.debug("\u{1F504} Reconnection attempt", { attempt: attemptNumber });
505
+ });
506
+ socket.on("reconnect_error", (error) => {
507
+ log.error("\u{1F504} Reconnection error", { message: error.message });
508
+ });
509
+ socket.on("reconnect_failed", () => {
510
+ log.error("\u{1F504} Reconnection failed after all attempts");
511
+ process.exit(1);
512
+ });
513
+ }
514
+ async function main() {
515
+ let log = null;
516
+ const safeLog = {
517
+ info: (message, extra) => {
518
+ if (log) log.info(message, extra);
519
+ else console.log(message, extra);
520
+ },
521
+ error: (message, extra) => {
522
+ if (log) log.error(message, extra);
523
+ else console.error(message, extra);
524
+ },
525
+ warn: (message, extra) => {
526
+ if (log) log.warn(message, extra);
527
+ else console.warn(message, extra);
528
+ },
529
+ debug: (message, extra) => {
530
+ if (log) log.debug(message, extra);
531
+ }
532
+ };
533
+ try {
534
+ log = Log.create({ service: "legion" });
535
+ const args = parseArgs();
536
+ if (args.command === "login") {
537
+ try {
538
+ await handleDeviceLogin(log);
539
+ process.exit(0);
540
+ } catch (error) {
541
+ log.error("\u274C Device login failed", { error });
316
542
  process.exit(1);
317
543
  }
318
- });
319
- socket.on("disconnect", (reason) => {
320
- log2.warn("\u26A0\uFE0F Disconnected from server", { reason });
321
- if (reason === "io server disconnect") {
322
- log2.error("\u{1F6AB} Server disconnected this client. Please check your token.");
544
+ }
545
+ if (args.token) {
546
+ log.info("\u{1F511} Saving token from command line...");
547
+ try {
548
+ const defaultServerUrl = process.env.TANUKI_SERVER_URL || "wss://tanuki.sabw.ru";
549
+ const existingConfig = await loadConfig();
550
+ await saveConfig({
551
+ token: args.token,
552
+ serverUrl: existingConfig?.serverUrl || defaultServerUrl,
553
+ ...existingConfig?.id ? { id: existingConfig.id } : {}
554
+ });
555
+ log.info("\u2705 Token saved successfully");
556
+ } catch (error) {
557
+ log.error("\u274C Failed to save token", { error });
323
558
  process.exit(1);
324
559
  }
325
- });
326
- socket.on("server:ping", (data2) => {
327
- log2.debug("\u{1F4E9} Ping from server", data2);
328
- socket.emit("legion:pong", { ts: Date.now() });
329
- });
330
- socket.on("reconnect", (attemptNumber) => {
331
- log2.info("\u{1F504} Reconnected to server", { attempt: attemptNumber });
332
- });
333
- socket.on("reconnect_attempt", (attemptNumber) => {
334
- log2.debug("\u{1F504} Reconnection attempt", { attempt: attemptNumber });
335
- });
336
- socket.on("reconnect_error", (error) => {
337
- log2.error("\u{1F504} Reconnection error", { message: error.message });
338
- });
339
- socket.on("reconnect_failed", () => {
340
- log2.error("\u{1F504} Reconnection failed after all attempts");
341
- process.exit(1);
342
- });
560
+ }
561
+ checkForUpdates();
562
+ await Global.init();
563
+ const fingerprint = await getSystemFingerprint();
564
+ log.info(`\u{1F6E1}\uFE0F Legion v${version} starting...`);
565
+ let config2 = await getConfig();
566
+ log.info("\u{1F517} Connecting to server", { serverUrl: config2.serverUrl });
567
+ let currentSocket;
568
+ let isTokenRotation = false;
569
+ let handleTokenRotation;
570
+ let handleReconnect;
571
+ handleReconnect = async () => {
572
+ if (currentSocket) {
573
+ currentSocket.io.opts.reconnection = false;
574
+ currentSocket.disconnect();
575
+ currentSocket.removeAllListeners();
576
+ await new Promise((resolve) => setTimeout(resolve, 500));
577
+ }
578
+ const newConfig = await getConfig();
579
+ log.info("\u{1F504} Reconnecting...");
580
+ currentSocket = createSocket(newConfig);
581
+ setupSocketHandlers(currentSocket, log, fingerprint, version, handleTokenRotation, handleReconnect);
582
+ config2 = newConfig;
583
+ };
584
+ handleTokenRotation = async () => {
585
+ isTokenRotation = true;
586
+ await handleReconnect();
587
+ isTokenRotation = false;
588
+ };
589
+ currentSocket = createSocket(config2);
590
+ setupSocketHandlers(currentSocket, log, fingerprint, version, handleTokenRotation, handleReconnect);
343
591
  process.stdin.resume();
344
592
  const shutdown = () => {
345
- log2.info("\u{1F6D1} Shutting down...");
346
- socket.disconnect();
593
+ log.info("\u{1F6D1} Shutting down...");
594
+ currentSocket.disconnect();
347
595
  process.exit(0);
348
596
  };
349
597
  process.on("SIGINT", shutdown);
350
598
  process.on("SIGTERM", shutdown);
351
599
  process.on("unhandledRejection", (reason) => {
352
- log2.error("Unhandled rejection", { reason });
600
+ log.error("Unhandled rejection", { reason });
353
601
  });
354
602
  process.on("uncaughtException", (error) => {
355
- log2.error("Uncaught exception", { error: error.message, stack: error.stack });
603
+ log.error("Uncaught exception", { error: error.message, stack: error.stack });
356
604
  shutdown();
357
605
  });
358
606
  } catch (error) {
359
607
  if (error instanceof Error) {
360
- log.error("Failed to start Legion", {
608
+ safeLog.error("Failed to start Legion", {
361
609
  message: error.message,
362
610
  stack: error.stack
363
611
  });
@@ -366,7 +614,7 @@ async function main() {
366
614
  console.error(" - Set LEGION_TOKEN environment variable, or");
367
615
  console.error(" - Create ~/.tanuki/config.json with your token");
368
616
  } else {
369
- log.error("Failed to start Legion", { error });
617
+ safeLog.error("Failed to start Legion", { error });
370
618
  console.error("\n\u274C Unknown error occurred");
371
619
  }
372
620
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@babylen/legion",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Legion agent for connecting devices to Tanuki Cloud",
5
5
  "main": "./dist/index.js",
6
6
  "keywords": [