@babylen/legion 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +457 -51
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -27,7 +27,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
27
27
|
var import_child_process = require("child_process");
|
|
28
28
|
|
|
29
29
|
// package.json
|
|
30
|
-
var version = "0.1.
|
|
30
|
+
var version = "0.1.5";
|
|
31
31
|
|
|
32
32
|
// src/index.ts
|
|
33
33
|
var import_socket = require("socket.io-client");
|
|
@@ -43,7 +43,9 @@ var ConfigSchema = import_zod.z.object({
|
|
|
43
43
|
// Token ID from database (for faster lookup)
|
|
44
44
|
token: import_zod.z.string(),
|
|
45
45
|
// Secret token (lg_xxx format)
|
|
46
|
-
serverUrl: import_zod.z.string().url()
|
|
46
|
+
serverUrl: import_zod.z.string().url(),
|
|
47
|
+
allowedPaths: import_zod.z.array(import_zod.z.string()).optional()
|
|
48
|
+
// Whitelist paths for file system access
|
|
47
49
|
});
|
|
48
50
|
var HOME_DIR = import_os.default.homedir();
|
|
49
51
|
var CONFIG_DIR = import_path.default.join(HOME_DIR, ".tanuki");
|
|
@@ -58,9 +60,14 @@ async function ensureConfigDir() {
|
|
|
58
60
|
}
|
|
59
61
|
async function loadConfig() {
|
|
60
62
|
try {
|
|
61
|
-
|
|
63
|
+
let content = await import_promises.default.readFile(CONFIG_FILE, "utf-8");
|
|
64
|
+
content = content.replace(/^\uFEFF/, "");
|
|
62
65
|
const data2 = JSON.parse(content);
|
|
63
|
-
|
|
66
|
+
const result = ConfigSchema.safeParse(data2);
|
|
67
|
+
if (!result.success) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return result.data;
|
|
64
71
|
} catch (error) {
|
|
65
72
|
return null;
|
|
66
73
|
}
|
|
@@ -75,6 +82,7 @@ async function saveConfig(config2) {
|
|
|
75
82
|
}
|
|
76
83
|
async function getConfig() {
|
|
77
84
|
const fileConfig = await loadConfig();
|
|
85
|
+
console.log("[DEBUG] Loaded config object:", fileConfig ? "FOUND" : "NULL");
|
|
78
86
|
const id = fileConfig?.id || process.env.LEGION_TOKEN_ID || void 0;
|
|
79
87
|
const token = fileConfig?.token || process.env.LEGION_TOKEN_SECRET || process.env.LEGION_TOKEN;
|
|
80
88
|
const serverUrl = fileConfig?.serverUrl || process.env.TANUKI_SERVER_URL || "wss://tanuki.sabw.ru";
|
|
@@ -90,8 +98,17 @@ async function getConfig() {
|
|
|
90
98
|
if (id) {
|
|
91
99
|
config2.id = id;
|
|
92
100
|
}
|
|
101
|
+
if (fileConfig?.allowedPaths) {
|
|
102
|
+
config2.allowedPaths = fileConfig.allowedPaths;
|
|
103
|
+
}
|
|
93
104
|
return config2;
|
|
94
105
|
}
|
|
106
|
+
function getAllowedPaths(config2) {
|
|
107
|
+
if (config2.allowedPaths && config2.allowedPaths.length > 0) {
|
|
108
|
+
return config2.allowedPaths.map((p) => import_path.default.resolve(p));
|
|
109
|
+
}
|
|
110
|
+
return [HOME_DIR];
|
|
111
|
+
}
|
|
95
112
|
async function updateConfig(updates) {
|
|
96
113
|
const currentConfig = await loadConfig();
|
|
97
114
|
if (!currentConfig) {
|
|
@@ -320,6 +337,389 @@ async function getSystemFingerprint() {
|
|
|
320
337
|
return cachedFingerprint;
|
|
321
338
|
}
|
|
322
339
|
|
|
340
|
+
// src/file/legion-bridge.ts
|
|
341
|
+
var import_promises4 = __toESM(require("fs/promises"));
|
|
342
|
+
var import_path4 = __toESM(require("path"));
|
|
343
|
+
var log = Log.create({ service: "file.legion-bridge" });
|
|
344
|
+
async function listFiles(targetPath, depth = 1) {
|
|
345
|
+
const resolved = import_path4.default.resolve(targetPath);
|
|
346
|
+
const exclude = [".git", ".DS_Store", ".tanuki"];
|
|
347
|
+
const nodes = [];
|
|
348
|
+
try {
|
|
349
|
+
const entries = await import_promises4.default.readdir(resolved, { withFileTypes: true });
|
|
350
|
+
for (const entry of entries) {
|
|
351
|
+
if (exclude.includes(entry.name)) continue;
|
|
352
|
+
const fullPath = import_path4.default.join(resolved, entry.name);
|
|
353
|
+
const isDirectory = entry.isDirectory();
|
|
354
|
+
const node = {
|
|
355
|
+
name: entry.name,
|
|
356
|
+
path: fullPath,
|
|
357
|
+
type: isDirectory ? "directory" : "file"
|
|
358
|
+
};
|
|
359
|
+
if (!isDirectory) {
|
|
360
|
+
try {
|
|
361
|
+
const stats = await import_promises4.default.stat(fullPath);
|
|
362
|
+
node.size = stats.size;
|
|
363
|
+
} catch {
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
nodes.push(node);
|
|
367
|
+
}
|
|
368
|
+
} catch (error) {
|
|
369
|
+
log.error("Failed to list directory", { error, targetPath });
|
|
370
|
+
throw error;
|
|
371
|
+
}
|
|
372
|
+
return nodes.sort((a, b) => {
|
|
373
|
+
if (a.type !== b.type) {
|
|
374
|
+
return a.type === "directory" ? -1 : 1;
|
|
375
|
+
}
|
|
376
|
+
return a.name.localeCompare(b.name);
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
async function shouldEncode(file) {
|
|
380
|
+
const type = file.type?.toLowerCase();
|
|
381
|
+
if (!type) return false;
|
|
382
|
+
if (type.startsWith("text/")) return false;
|
|
383
|
+
if (type.includes("charset=")) return false;
|
|
384
|
+
const parts = type.split("/", 2);
|
|
385
|
+
const top = parts[0];
|
|
386
|
+
const rest = parts[1] ?? "";
|
|
387
|
+
const sub = rest.split(";", 1)[0];
|
|
388
|
+
const tops = ["image", "audio", "video", "font", "model", "multipart"];
|
|
389
|
+
if (tops.includes(top)) return true;
|
|
390
|
+
const bins = [
|
|
391
|
+
"zip",
|
|
392
|
+
"gzip",
|
|
393
|
+
"bzip",
|
|
394
|
+
"compressed",
|
|
395
|
+
"binary",
|
|
396
|
+
"pdf",
|
|
397
|
+
"msword",
|
|
398
|
+
"vnd.ms",
|
|
399
|
+
"octet-stream"
|
|
400
|
+
];
|
|
401
|
+
if (bins.includes(sub)) return true;
|
|
402
|
+
try {
|
|
403
|
+
const buffer = await file.arrayBuffer();
|
|
404
|
+
if (buffer.byteLength === 0) return false;
|
|
405
|
+
const view = new Uint8Array(buffer.slice(0, 512));
|
|
406
|
+
return view.some((byte) => byte === 0);
|
|
407
|
+
} catch {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
async function readFile(filePath, maxSize = 1024 * 1024) {
|
|
412
|
+
const resolved = import_path4.default.resolve(filePath);
|
|
413
|
+
const bunFile = Bun.file(resolved);
|
|
414
|
+
if (!await bunFile.exists()) {
|
|
415
|
+
throw new Error(`File not found: ${filePath}`);
|
|
416
|
+
}
|
|
417
|
+
let stats;
|
|
418
|
+
try {
|
|
419
|
+
stats = await import_promises4.default.stat(resolved);
|
|
420
|
+
} catch (error) {
|
|
421
|
+
throw new Error(`Failed to stat file: ${filePath}`);
|
|
422
|
+
}
|
|
423
|
+
if (stats.size > maxSize) {
|
|
424
|
+
return {
|
|
425
|
+
type: "blob",
|
|
426
|
+
size: stats.size,
|
|
427
|
+
error: "too_large"
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
const encode = await shouldEncode(bunFile);
|
|
431
|
+
if (encode) {
|
|
432
|
+
const buffer = await bunFile.arrayBuffer();
|
|
433
|
+
const content2 = Buffer.from(buffer).toString("base64");
|
|
434
|
+
const mimeType = bunFile.type || "application/octet-stream";
|
|
435
|
+
return {
|
|
436
|
+
type: "blob",
|
|
437
|
+
content: content2,
|
|
438
|
+
encoding: "base64",
|
|
439
|
+
mimeType,
|
|
440
|
+
size: stats.size
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
const content = await bunFile.text().catch(() => "");
|
|
444
|
+
return {
|
|
445
|
+
type: "text",
|
|
446
|
+
content,
|
|
447
|
+
encoding: "utf-8",
|
|
448
|
+
size: stats.size
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// src/core/path-validator.ts
|
|
453
|
+
var import_path6 = __toESM(require("path"));
|
|
454
|
+
var import_fs3 = require("fs");
|
|
455
|
+
|
|
456
|
+
// src/util/filesystem.ts
|
|
457
|
+
var import_fs2 = require("fs");
|
|
458
|
+
var import_path5 = require("path");
|
|
459
|
+
var Filesystem;
|
|
460
|
+
((Filesystem2) => {
|
|
461
|
+
Filesystem2.exists = (p) => Bun.file(p).stat().then(() => true).catch(() => false);
|
|
462
|
+
Filesystem2.isDir = (p) => Bun.file(p).stat().then((s) => s.isDirectory()).catch(() => false);
|
|
463
|
+
function normalizePath(p) {
|
|
464
|
+
if (process.platform !== "win32") return p;
|
|
465
|
+
try {
|
|
466
|
+
return import_fs2.realpathSync.native(p);
|
|
467
|
+
} catch {
|
|
468
|
+
return p;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
Filesystem2.normalizePath = normalizePath;
|
|
472
|
+
function overlaps(a, b) {
|
|
473
|
+
const relA = (0, import_path5.relative)(a, b);
|
|
474
|
+
const relB = (0, import_path5.relative)(b, a);
|
|
475
|
+
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..");
|
|
476
|
+
}
|
|
477
|
+
Filesystem2.overlaps = overlaps;
|
|
478
|
+
function contains(parent, child) {
|
|
479
|
+
return !(0, import_path5.relative)(parent, child).startsWith("..");
|
|
480
|
+
}
|
|
481
|
+
Filesystem2.contains = contains;
|
|
482
|
+
async function findUp(target, start, stop) {
|
|
483
|
+
let current = start;
|
|
484
|
+
const result = [];
|
|
485
|
+
while (true) {
|
|
486
|
+
const search = (0, import_path5.join)(current, target);
|
|
487
|
+
if (await (0, Filesystem2.exists)(search)) result.push(search);
|
|
488
|
+
if (stop === current) break;
|
|
489
|
+
const parent = (0, import_path5.dirname)(current);
|
|
490
|
+
if (parent === current) break;
|
|
491
|
+
current = parent;
|
|
492
|
+
}
|
|
493
|
+
return result;
|
|
494
|
+
}
|
|
495
|
+
Filesystem2.findUp = findUp;
|
|
496
|
+
async function* up(options) {
|
|
497
|
+
const { targets, start, stop } = options;
|
|
498
|
+
let current = start;
|
|
499
|
+
while (true) {
|
|
500
|
+
for (const target of targets) {
|
|
501
|
+
const search = (0, import_path5.join)(current, target);
|
|
502
|
+
if (await (0, Filesystem2.exists)(search)) yield search;
|
|
503
|
+
}
|
|
504
|
+
if (stop === current) break;
|
|
505
|
+
const parent = (0, import_path5.dirname)(current);
|
|
506
|
+
if (parent === current) break;
|
|
507
|
+
current = parent;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
Filesystem2.up = up;
|
|
511
|
+
async function globUp(pattern, start, stop) {
|
|
512
|
+
let current = start;
|
|
513
|
+
const result = [];
|
|
514
|
+
while (true) {
|
|
515
|
+
try {
|
|
516
|
+
const glob = new Bun.Glob(pattern);
|
|
517
|
+
for await (const match of glob.scan({
|
|
518
|
+
cwd: current,
|
|
519
|
+
absolute: true,
|
|
520
|
+
onlyFiles: true,
|
|
521
|
+
followSymlinks: true,
|
|
522
|
+
dot: true
|
|
523
|
+
})) {
|
|
524
|
+
result.push(match);
|
|
525
|
+
}
|
|
526
|
+
} catch {
|
|
527
|
+
}
|
|
528
|
+
if (stop === current) break;
|
|
529
|
+
const parent = (0, import_path5.dirname)(current);
|
|
530
|
+
if (parent === current) break;
|
|
531
|
+
current = parent;
|
|
532
|
+
}
|
|
533
|
+
return result;
|
|
534
|
+
}
|
|
535
|
+
Filesystem2.globUp = globUp;
|
|
536
|
+
})(Filesystem || (Filesystem = {}));
|
|
537
|
+
|
|
538
|
+
// src/core/path-validator.ts
|
|
539
|
+
var log2 = Log.create({ service: "path-validator" });
|
|
540
|
+
function isPathAllowed(requestedPath, allowedPaths) {
|
|
541
|
+
try {
|
|
542
|
+
const normalized = import_path6.default.resolve(requestedPath);
|
|
543
|
+
let realPath;
|
|
544
|
+
try {
|
|
545
|
+
realPath = (0, import_fs3.realpathSync)(normalized);
|
|
546
|
+
} catch {
|
|
547
|
+
realPath = normalized;
|
|
548
|
+
}
|
|
549
|
+
for (const allowed of allowedPaths) {
|
|
550
|
+
const allowedResolved = import_path6.default.resolve(allowed);
|
|
551
|
+
if (Filesystem.contains(allowedResolved, realPath)) {
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
log2.warn("Path access denied", { requestedPath, realPath, allowedPaths });
|
|
556
|
+
return false;
|
|
557
|
+
} catch (error) {
|
|
558
|
+
log2.error("Path validation error", { error, requestedPath });
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// src/socket/handlers/fs-list.ts
|
|
564
|
+
async function handleFsList(req, socket) {
|
|
565
|
+
const { path: requestedPath, depth = 1 } = req;
|
|
566
|
+
const targetPath = requestedPath || process.cwd();
|
|
567
|
+
const config2 = socket.legionConfig;
|
|
568
|
+
const allowedPaths = getAllowedPaths(config2);
|
|
569
|
+
if (!isPathAllowed(targetPath, allowedPaths)) {
|
|
570
|
+
return {
|
|
571
|
+
id: req.id,
|
|
572
|
+
status: "error",
|
|
573
|
+
error: "Access denied: path not in whitelist"
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
const files = await listFiles(targetPath, depth);
|
|
578
|
+
return {
|
|
579
|
+
id: req.id,
|
|
580
|
+
status: "ok",
|
|
581
|
+
data: files
|
|
582
|
+
};
|
|
583
|
+
} catch (error) {
|
|
584
|
+
return {
|
|
585
|
+
id: req.id,
|
|
586
|
+
status: "error",
|
|
587
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// src/socket/handlers/fs-read.ts
|
|
593
|
+
async function handleFsRead(req, socket) {
|
|
594
|
+
const { path: requestedPath, maxSize } = req;
|
|
595
|
+
if (!requestedPath) {
|
|
596
|
+
return {
|
|
597
|
+
id: req.id,
|
|
598
|
+
status: "error",
|
|
599
|
+
error: "Path is required"
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
const config2 = socket.legionConfig;
|
|
603
|
+
const allowedPaths = getAllowedPaths(config2);
|
|
604
|
+
if (!isPathAllowed(requestedPath, allowedPaths)) {
|
|
605
|
+
return {
|
|
606
|
+
id: req.id,
|
|
607
|
+
status: "error",
|
|
608
|
+
error: "Access denied: path not in whitelist"
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
const content = await readFile(requestedPath, maxSize || 1024 * 1024);
|
|
613
|
+
return {
|
|
614
|
+
id: req.id,
|
|
615
|
+
status: "ok",
|
|
616
|
+
data: content
|
|
617
|
+
};
|
|
618
|
+
} catch (error) {
|
|
619
|
+
return {
|
|
620
|
+
id: req.id,
|
|
621
|
+
status: "error",
|
|
622
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// src/project/binding.ts
|
|
628
|
+
var import_promises5 = __toESM(require("fs/promises"));
|
|
629
|
+
var import_path7 = __toESM(require("path"));
|
|
630
|
+
var log3 = Log.create({ service: "project.binding" });
|
|
631
|
+
async function bindProject(targetPath, projectId, projectName) {
|
|
632
|
+
const resolved = import_path7.default.resolve(targetPath);
|
|
633
|
+
const tanukiDir = import_path7.default.join(resolved, ".tanuki");
|
|
634
|
+
const projectFile = import_path7.default.join(tanukiDir, "project.json");
|
|
635
|
+
await import_promises5.default.mkdir(tanukiDir, { recursive: true });
|
|
636
|
+
const config2 = {
|
|
637
|
+
id: projectId,
|
|
638
|
+
name: projectName || import_path7.default.basename(resolved)
|
|
639
|
+
};
|
|
640
|
+
await import_promises5.default.writeFile(projectFile, JSON.stringify(config2, null, 2), "utf-8");
|
|
641
|
+
const gitignorePath = import_path7.default.join(resolved, ".gitignore");
|
|
642
|
+
try {
|
|
643
|
+
let gitignoreContent = await import_promises5.default.readFile(gitignorePath, "utf-8");
|
|
644
|
+
if (!gitignoreContent.includes(".tanuki")) {
|
|
645
|
+
gitignoreContent += "\n.tanuki\n";
|
|
646
|
+
await import_promises5.default.writeFile(gitignorePath, gitignoreContent, "utf-8");
|
|
647
|
+
}
|
|
648
|
+
} catch {
|
|
649
|
+
await import_promises5.default.writeFile(gitignorePath, ".tanuki\n", "utf-8");
|
|
650
|
+
}
|
|
651
|
+
log3.info("Project bound", { path: resolved, projectId });
|
|
652
|
+
return projectFile;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// src/socket/handlers/project-bind.ts
|
|
656
|
+
async function handleProjectBind(req, socket) {
|
|
657
|
+
const { path: requestedPath, projectId, projectName } = req;
|
|
658
|
+
if (!requestedPath) {
|
|
659
|
+
return {
|
|
660
|
+
id: req.id,
|
|
661
|
+
status: "error",
|
|
662
|
+
error: "Path is required"
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
if (!projectId) {
|
|
666
|
+
return {
|
|
667
|
+
id: req.id,
|
|
668
|
+
status: "error",
|
|
669
|
+
error: "projectId is required"
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
const config2 = socket.legionConfig;
|
|
673
|
+
const allowedPaths = getAllowedPaths(config2);
|
|
674
|
+
if (!isPathAllowed(requestedPath, allowedPaths)) {
|
|
675
|
+
return {
|
|
676
|
+
id: req.id,
|
|
677
|
+
status: "error",
|
|
678
|
+
error: "Access denied: path not in whitelist"
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
try {
|
|
682
|
+
const configPath = await bindProject(requestedPath, projectId, projectName);
|
|
683
|
+
return {
|
|
684
|
+
id: req.id,
|
|
685
|
+
status: "ok",
|
|
686
|
+
data: { configPath }
|
|
687
|
+
};
|
|
688
|
+
} catch (error) {
|
|
689
|
+
return {
|
|
690
|
+
id: req.id,
|
|
691
|
+
status: "error",
|
|
692
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/socket/dispatcher.ts
|
|
698
|
+
var handlers = {
|
|
699
|
+
"legion:fs:list": handleFsList,
|
|
700
|
+
"legion:fs:read": handleFsRead,
|
|
701
|
+
"legion:project:bind": handleProjectBind
|
|
702
|
+
};
|
|
703
|
+
function setupDispatcher(socket, log4, config2) {
|
|
704
|
+
socket.legionConfig = config2;
|
|
705
|
+
for (const [event, handler] of Object.entries(handlers)) {
|
|
706
|
+
socket.on(event, async (request) => {
|
|
707
|
+
try {
|
|
708
|
+
const response = await handler(request, socket);
|
|
709
|
+
socket.emit(`${event}:response`, response);
|
|
710
|
+
} catch (error) {
|
|
711
|
+
log4.error(`Handler error for ${event}`, { error, request });
|
|
712
|
+
socket.emit(`${event}:response`, {
|
|
713
|
+
id: request.id,
|
|
714
|
+
status: "error",
|
|
715
|
+
error: error instanceof Error ? error.message : "Unknown error"
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
log4.info("Socket dispatcher initialized", { events: Object.keys(handlers) });
|
|
721
|
+
}
|
|
722
|
+
|
|
323
723
|
// src/index.ts
|
|
324
724
|
if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
|
325
725
|
console.log(version);
|
|
@@ -344,7 +744,7 @@ function parseArgs() {
|
|
|
344
744
|
}
|
|
345
745
|
return parsed;
|
|
346
746
|
}
|
|
347
|
-
async function handleDeviceLogin(
|
|
747
|
+
async function handleDeviceLogin(log4, serverUrl = "https://tanuki.sabw.ru") {
|
|
348
748
|
const codeEndpoint = `${serverUrl}/api/v1/legion/device/code`;
|
|
349
749
|
const tokenEndpoint = `${serverUrl}/api/v1/legion/device/token`;
|
|
350
750
|
const codeResponse = await fetch(codeEndpoint, {
|
|
@@ -374,7 +774,7 @@ async function handleDeviceLogin(log, serverUrl = "https://tanuki.sabw.ru") {
|
|
|
374
774
|
id: token_id,
|
|
375
775
|
serverUrl: existingConfig?.serverUrl || "wss://tanuki.sabw.ru"
|
|
376
776
|
});
|
|
377
|
-
|
|
777
|
+
log4.info("\u2705 Device activated successfully");
|
|
378
778
|
return;
|
|
379
779
|
} else if (tokenResponse.status === 202) {
|
|
380
780
|
continue;
|
|
@@ -405,20 +805,20 @@ function checkForUpdates() {
|
|
|
405
805
|
}
|
|
406
806
|
}, 100);
|
|
407
807
|
}
|
|
408
|
-
async function enterHibernationMode(
|
|
409
|
-
|
|
410
|
-
const
|
|
808
|
+
async function enterHibernationMode(log4, configFile, onConfigChanged) {
|
|
809
|
+
log4.warn("\u26D4 Auth failed. Entering hibernation mode. Waiting for config update...");
|
|
810
|
+
const fs6 = await import("fs");
|
|
411
811
|
return new Promise((resolve) => {
|
|
412
|
-
const watchHandle =
|
|
812
|
+
const watchHandle = fs6.watch(configFile, async (eventType) => {
|
|
413
813
|
if (eventType === "change") {
|
|
414
|
-
|
|
814
|
+
log4.info("\u{1F4DD} Config file changed. Attempting reconnection...");
|
|
415
815
|
try {
|
|
416
816
|
await new Promise((resolve2) => setTimeout(resolve2, 100));
|
|
417
817
|
await onConfigChanged();
|
|
418
818
|
watchHandle.close();
|
|
419
819
|
resolve();
|
|
420
820
|
} catch (error) {
|
|
421
|
-
|
|
821
|
+
log4.error("\u274C Reconnection failed", { error });
|
|
422
822
|
}
|
|
423
823
|
}
|
|
424
824
|
});
|
|
@@ -440,9 +840,9 @@ function createSocket(config2) {
|
|
|
440
840
|
reconnectionAttempts: Infinity
|
|
441
841
|
});
|
|
442
842
|
}
|
|
443
|
-
function setupSocketHandlers(socket,
|
|
843
|
+
function setupSocketHandlers(socket, log4, fingerprint, version2, onTokenRotation, onReconnect) {
|
|
444
844
|
socket.on("connect", () => {
|
|
445
|
-
|
|
845
|
+
log4.info("\u2705 Connected to server", { socketId: socket.id });
|
|
446
846
|
const handshakeData = {
|
|
447
847
|
fingerprint,
|
|
448
848
|
version: version2,
|
|
@@ -452,98 +852,102 @@ function setupSocketHandlers(socket, log, fingerprint, version2, onTokenRotation
|
|
|
452
852
|
cwd: process.cwd()
|
|
453
853
|
};
|
|
454
854
|
socket.emit("legion:handshake", handshakeData);
|
|
455
|
-
|
|
855
|
+
log4.debug("\u{1F4E4} Sent handshake", handshakeData);
|
|
856
|
+
const socketConfig = socket.legionConfig;
|
|
857
|
+
if (socketConfig) {
|
|
858
|
+
setupDispatcher(socket, log4, socketConfig);
|
|
859
|
+
}
|
|
456
860
|
});
|
|
457
861
|
socket.on("connect_error", (err) => {
|
|
458
|
-
|
|
862
|
+
log4.error("\u274C Connection error", {
|
|
459
863
|
message: err.message,
|
|
460
864
|
type: err.type
|
|
461
865
|
});
|
|
462
866
|
if (err.message.includes("auth") || err.message.includes("token")) {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
enterHibernationMode(
|
|
466
|
-
|
|
867
|
+
log4.error("\u{1F510} Authentication failed. Please check your token.");
|
|
868
|
+
log4.error("\u{1F4A1} Re-authenticate by updating ~/.tanuki/config.json or LEGION_TOKEN env var");
|
|
869
|
+
enterHibernationMode(log4, CONFIG_FILE, onReconnect).catch((error) => {
|
|
870
|
+
log4.error("\u274C Hibernation mode failed", { error });
|
|
467
871
|
process.exit(1);
|
|
468
872
|
});
|
|
469
873
|
}
|
|
470
874
|
});
|
|
471
875
|
socket.on("disconnect", (reason) => {
|
|
472
|
-
|
|
876
|
+
log4.warn("\u26A0\uFE0F Disconnected from server", { reason });
|
|
473
877
|
if (reason === "io server disconnect") {
|
|
474
|
-
|
|
475
|
-
enterHibernationMode(
|
|
476
|
-
|
|
878
|
+
log4.error("\u{1F6AB} Server disconnected this client. Please check your token.");
|
|
879
|
+
enterHibernationMode(log4, CONFIG_FILE, onReconnect).catch((error) => {
|
|
880
|
+
log4.error("\u274C Hibernation mode failed", { error });
|
|
477
881
|
process.exit(1);
|
|
478
882
|
});
|
|
479
883
|
}
|
|
480
884
|
});
|
|
481
885
|
socket.on("legion:update_token", async (data2) => {
|
|
482
886
|
try {
|
|
483
|
-
|
|
887
|
+
log4.info("\u{1F504} Received permanent credentials. Persisting...");
|
|
484
888
|
await updateConfig({
|
|
485
889
|
token: data2.secret,
|
|
486
890
|
// secret -> token
|
|
487
891
|
id: data2.token_id
|
|
488
892
|
// token_id -> id
|
|
489
893
|
});
|
|
490
|
-
|
|
894
|
+
log4.info("\u2705 Config updated successfully. Reconnecting with new token...");
|
|
491
895
|
await onTokenRotation();
|
|
492
896
|
} catch (error) {
|
|
493
|
-
|
|
897
|
+
log4.error("\u274C Failed to update config", { error });
|
|
494
898
|
}
|
|
495
899
|
});
|
|
496
900
|
socket.on("server:ping", (data2) => {
|
|
497
|
-
|
|
901
|
+
log4.debug("\u{1F4E9} Ping from server", data2);
|
|
498
902
|
socket.emit("legion:pong", { ts: Date.now() });
|
|
499
903
|
});
|
|
500
904
|
socket.on("reconnect", (attemptNumber) => {
|
|
501
|
-
|
|
905
|
+
log4.info("\u{1F504} Reconnected to server", { attempt: attemptNumber });
|
|
502
906
|
});
|
|
503
907
|
socket.on("reconnect_attempt", (attemptNumber) => {
|
|
504
|
-
|
|
908
|
+
log4.debug("\u{1F504} Reconnection attempt", { attempt: attemptNumber });
|
|
505
909
|
});
|
|
506
910
|
socket.on("reconnect_error", (error) => {
|
|
507
|
-
|
|
911
|
+
log4.error("\u{1F504} Reconnection error", { message: error.message });
|
|
508
912
|
});
|
|
509
913
|
socket.on("reconnect_failed", () => {
|
|
510
|
-
|
|
914
|
+
log4.error("\u{1F504} Reconnection failed after all attempts");
|
|
511
915
|
process.exit(1);
|
|
512
916
|
});
|
|
513
917
|
}
|
|
514
918
|
async function main() {
|
|
515
|
-
let
|
|
919
|
+
let log4 = null;
|
|
516
920
|
const safeLog = {
|
|
517
921
|
info: (message, extra) => {
|
|
518
|
-
if (
|
|
922
|
+
if (log4) log4.info(message, extra);
|
|
519
923
|
else console.log(message, extra);
|
|
520
924
|
},
|
|
521
925
|
error: (message, extra) => {
|
|
522
|
-
if (
|
|
926
|
+
if (log4) log4.error(message, extra);
|
|
523
927
|
else console.error(message, extra);
|
|
524
928
|
},
|
|
525
929
|
warn: (message, extra) => {
|
|
526
|
-
if (
|
|
930
|
+
if (log4) log4.warn(message, extra);
|
|
527
931
|
else console.warn(message, extra);
|
|
528
932
|
},
|
|
529
933
|
debug: (message, extra) => {
|
|
530
|
-
if (
|
|
934
|
+
if (log4) log4.debug(message, extra);
|
|
531
935
|
}
|
|
532
936
|
};
|
|
533
937
|
try {
|
|
534
|
-
|
|
938
|
+
log4 = Log.create({ service: "legion" });
|
|
535
939
|
const args = parseArgs();
|
|
536
940
|
if (args.command === "login") {
|
|
537
941
|
try {
|
|
538
|
-
await handleDeviceLogin(
|
|
942
|
+
await handleDeviceLogin(log4);
|
|
539
943
|
process.exit(0);
|
|
540
944
|
} catch (error) {
|
|
541
|
-
|
|
945
|
+
log4.error("\u274C Device login failed", { error });
|
|
542
946
|
process.exit(1);
|
|
543
947
|
}
|
|
544
948
|
}
|
|
545
949
|
if (args.token) {
|
|
546
|
-
|
|
950
|
+
log4.info("\u{1F511} Saving token from command line...");
|
|
547
951
|
try {
|
|
548
952
|
const defaultServerUrl = process.env.TANUKI_SERVER_URL || "wss://tanuki.sabw.ru";
|
|
549
953
|
const existingConfig = await loadConfig();
|
|
@@ -552,18 +956,18 @@ async function main() {
|
|
|
552
956
|
serverUrl: existingConfig?.serverUrl || defaultServerUrl,
|
|
553
957
|
...existingConfig?.id ? { id: existingConfig.id } : {}
|
|
554
958
|
});
|
|
555
|
-
|
|
959
|
+
log4.info("\u2705 Token saved successfully");
|
|
556
960
|
} catch (error) {
|
|
557
|
-
|
|
961
|
+
log4.error("\u274C Failed to save token", { error });
|
|
558
962
|
process.exit(1);
|
|
559
963
|
}
|
|
560
964
|
}
|
|
561
965
|
checkForUpdates();
|
|
562
966
|
await Global.init();
|
|
563
967
|
const fingerprint = await getSystemFingerprint();
|
|
564
|
-
|
|
968
|
+
log4.info(`\u{1F6E1}\uFE0F Legion v${version} starting...`);
|
|
565
969
|
let config2 = await getConfig();
|
|
566
|
-
|
|
970
|
+
log4.info("\u{1F517} Connecting to server", { serverUrl: config2.serverUrl });
|
|
567
971
|
let currentSocket;
|
|
568
972
|
let isTokenRotation = false;
|
|
569
973
|
let handleTokenRotation;
|
|
@@ -576,9 +980,10 @@ async function main() {
|
|
|
576
980
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
577
981
|
}
|
|
578
982
|
const newConfig = await getConfig();
|
|
579
|
-
|
|
983
|
+
log4.info("\u{1F504} Reconnecting...");
|
|
580
984
|
currentSocket = createSocket(newConfig);
|
|
581
|
-
|
|
985
|
+
currentSocket.legionConfig = newConfig;
|
|
986
|
+
setupSocketHandlers(currentSocket, log4, fingerprint, version, handleTokenRotation, handleReconnect);
|
|
582
987
|
config2 = newConfig;
|
|
583
988
|
};
|
|
584
989
|
handleTokenRotation = async () => {
|
|
@@ -587,20 +992,21 @@ async function main() {
|
|
|
587
992
|
isTokenRotation = false;
|
|
588
993
|
};
|
|
589
994
|
currentSocket = createSocket(config2);
|
|
590
|
-
|
|
995
|
+
currentSocket.legionConfig = config2;
|
|
996
|
+
setupSocketHandlers(currentSocket, log4, fingerprint, version, handleTokenRotation, handleReconnect);
|
|
591
997
|
process.stdin.resume();
|
|
592
998
|
const shutdown = () => {
|
|
593
|
-
|
|
999
|
+
log4.info("\u{1F6D1} Shutting down...");
|
|
594
1000
|
currentSocket.disconnect();
|
|
595
1001
|
process.exit(0);
|
|
596
1002
|
};
|
|
597
1003
|
process.on("SIGINT", shutdown);
|
|
598
1004
|
process.on("SIGTERM", shutdown);
|
|
599
1005
|
process.on("unhandledRejection", (reason) => {
|
|
600
|
-
|
|
1006
|
+
log4.error("Unhandled rejection", { reason });
|
|
601
1007
|
});
|
|
602
1008
|
process.on("uncaughtException", (error) => {
|
|
603
|
-
|
|
1009
|
+
log4.error("Uncaught exception", { error: error.message, stack: error.stack });
|
|
604
1010
|
shutdown();
|
|
605
1011
|
});
|
|
606
1012
|
} catch (error) {
|