@sstar/embedlink_agent 0.1.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/README.md +107 -0
- package/dist/.platform +1 -0
- package/dist/board/docs.js +59 -0
- package/dist/board/notes.js +11 -0
- package/dist/board_uart/history.js +81 -0
- package/dist/board_uart/index.js +66 -0
- package/dist/board_uart/manager.js +313 -0
- package/dist/board_uart/resource.js +578 -0
- package/dist/board_uart/sessions.js +559 -0
- package/dist/config/index.js +341 -0
- package/dist/core/activity.js +7 -0
- package/dist/core/errors.js +45 -0
- package/dist/core/log_stream.js +26 -0
- package/dist/files/__tests__/files_manager.test.js +209 -0
- package/dist/files/artifact_manager.js +68 -0
- package/dist/files/file_operation_logger.js +271 -0
- package/dist/files/files_manager.js +511 -0
- package/dist/files/index.js +87 -0
- package/dist/files/types.js +5 -0
- package/dist/firmware/burn_recover.js +733 -0
- package/dist/firmware/prepare_images.js +184 -0
- package/dist/firmware/user_guide.js +43 -0
- package/dist/index.js +449 -0
- package/dist/logger.js +245 -0
- package/dist/macro/index.js +241 -0
- package/dist/macro/runner.js +168 -0
- package/dist/nfs/index.js +105 -0
- package/dist/plugins/loader.js +30 -0
- package/dist/proto/agent.proto +473 -0
- package/dist/resources/docs/board-interaction.md +115 -0
- package/dist/resources/docs/firmware-upgrade.md +404 -0
- package/dist/resources/docs/nfs-mount-guide.md +78 -0
- package/dist/resources/docs/tftp-transfer-guide.md +81 -0
- package/dist/secrets/index.js +9 -0
- package/dist/server/grpc.js +1069 -0
- package/dist/server/web.js +2284 -0
- package/dist/ssh/adapter.js +126 -0
- package/dist/ssh/candidates.js +85 -0
- package/dist/ssh/index.js +3 -0
- package/dist/ssh/paircheck.js +35 -0
- package/dist/ssh/tunnel.js +111 -0
- package/dist/tftp/client.js +345 -0
- package/dist/tftp/index.js +284 -0
- package/dist/tftp/server.js +731 -0
- package/dist/uboot/index.js +45 -0
- package/dist/ui/assets/index-BlnLVmbt.js +374 -0
- package/dist/ui/assets/index-xMbarYXA.css +32 -0
- package/dist/ui/index.html +21 -0
- package/dist/utils/network.js +150 -0
- package/dist/utils/platform.js +83 -0
- package/dist/utils/port-check.js +153 -0
- package/dist/utils/user-prompt.js +139 -0
- package/package.json +64 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { encode } from '@toon-format/toon';
|
|
4
|
+
// 返回类型已简化为纯字符串格式(TOON 格式分区信息)
|
|
5
|
+
// 从 partition_toon.js 移植的解析函数
|
|
6
|
+
async function parsePartitionLayout(filePath) {
|
|
7
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
8
|
+
const lines = content.split('\n').map(line => line.trim()).filter(line => line);
|
|
9
|
+
const result = {
|
|
10
|
+
logicalGroups: [],
|
|
11
|
+
partitions: [],
|
|
12
|
+
metadata: {
|
|
13
|
+
flashUsed: null,
|
|
14
|
+
checksum: null,
|
|
15
|
+
magic: null,
|
|
16
|
+
checksumOk: false
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
// 解析逻辑分组信息
|
|
20
|
+
lines.forEach((line, index) => {
|
|
21
|
+
if (index >= 8)
|
|
22
|
+
return; // 跳过分区表部分
|
|
23
|
+
const logicalGroupMatch = line.match(/^(\w+(?:\s+\w+)*?):\s*(.+)$/);
|
|
24
|
+
if (logicalGroupMatch) {
|
|
25
|
+
const groupName = logicalGroupMatch[1];
|
|
26
|
+
const partitionSpecs = logicalGroupMatch[2];
|
|
27
|
+
const partitions = partitionSpecs.split(',').map(spec => {
|
|
28
|
+
const match = spec.trim().match(/^(0x[0-9a-fA-F]+|-)\(([^)]+)\)$/);
|
|
29
|
+
if (match) {
|
|
30
|
+
return {
|
|
31
|
+
address: match[1],
|
|
32
|
+
name: match[2]
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}).filter(p => p !== null);
|
|
37
|
+
result.logicalGroups.push({
|
|
38
|
+
name: groupName,
|
|
39
|
+
partitions: partitions
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
// 解析 FLASH 使用量
|
|
43
|
+
const flashUsedMatch = line.match(/^FLASH\s+HAS\s+USED\s+(0x[0-9a-fA-F]+)KB$/);
|
|
44
|
+
if (flashUsedMatch) {
|
|
45
|
+
result.metadata.flashUsed = flashUsedMatch[1];
|
|
46
|
+
}
|
|
47
|
+
// 解析 Checksum
|
|
48
|
+
const checksumMatch = line.match(/^ChkSum\s*:\s*(\d+)$/);
|
|
49
|
+
if (checksumMatch) {
|
|
50
|
+
result.metadata.checksum = checksumMatch[1];
|
|
51
|
+
}
|
|
52
|
+
// 解析 Magic
|
|
53
|
+
const magicMatch = line.match(/^Magic\s*:\s*(.+)$/);
|
|
54
|
+
if (magicMatch) {
|
|
55
|
+
result.metadata.magic = magicMatch[1];
|
|
56
|
+
}
|
|
57
|
+
// 检查 Checksum 状态
|
|
58
|
+
if (line.includes('Checksum ok!!')) {
|
|
59
|
+
result.metadata.checksumOk = true;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
// 解析详细分区表
|
|
63
|
+
let headerFound = false;
|
|
64
|
+
lines.forEach(line => {
|
|
65
|
+
// 查找分区表头部
|
|
66
|
+
if (line.includes('IDX:') && line.includes('StartBlk:') && line.includes('Name:')) {
|
|
67
|
+
headerFound = true;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// 解析分区条目
|
|
71
|
+
if (headerFound && line.match(/^\s*\d+:/)) {
|
|
72
|
+
const parts = line.trim().split(/\s+/);
|
|
73
|
+
if (parts.length >= 8) {
|
|
74
|
+
const indexPart = parts[0].match(/^(\d+):$/);
|
|
75
|
+
const startBlockPart = parts[1].match(/^(\d+),\((0?X?[0-9a-fA-F]+)\)$/);
|
|
76
|
+
const blockCountPart = parts[2].match(/^(\d+),\((0?X?[0-9a-fA-F]+)\)$/);
|
|
77
|
+
const trunkBackupPart = parts[3].match(/^(\d+)\/(\d+)$/);
|
|
78
|
+
if (indexPart && startBlockPart && blockCountPart && trunkBackupPart) {
|
|
79
|
+
const normalizeAddress = (addr) => {
|
|
80
|
+
if (addr.startsWith('0x'))
|
|
81
|
+
return '0x' + addr.substring(2).toLowerCase();
|
|
82
|
+
if (addr.startsWith('0X'))
|
|
83
|
+
return '0x' + addr.substring(2).toLowerCase();
|
|
84
|
+
return '0x' + addr.toLowerCase();
|
|
85
|
+
};
|
|
86
|
+
const startAddress = normalizeAddress(startBlockPart[2]);
|
|
87
|
+
const blockSize = normalizeAddress(blockCountPart[2]);
|
|
88
|
+
result.partitions.push({
|
|
89
|
+
index: parseInt(indexPart[1]),
|
|
90
|
+
startBlock: {
|
|
91
|
+
number: parseInt(startBlockPart[1]),
|
|
92
|
+
address: startAddress
|
|
93
|
+
},
|
|
94
|
+
blockCount: {
|
|
95
|
+
count: parseInt(blockCountPart[1]),
|
|
96
|
+
size: blockSize
|
|
97
|
+
},
|
|
98
|
+
trunkBackup: `${trunkBackupPart[1]}/${trunkBackupPart[2]}`,
|
|
99
|
+
group: parseInt(parts[4]),
|
|
100
|
+
active: parseInt(parts[5]),
|
|
101
|
+
name: parts[6],
|
|
102
|
+
busCs: parts[7]
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
// 转换分区数据为 TOON 格式
|
|
111
|
+
function convertToToon(partitionData) {
|
|
112
|
+
const physicalPartitions = partitionData.partitions || [];
|
|
113
|
+
const logicalGroups = partitionData.logicalGroups || [];
|
|
114
|
+
// 构建简洁的数据结构
|
|
115
|
+
const toonData = {
|
|
116
|
+
partition_map: physicalPartitions.map((p) => ({
|
|
117
|
+
idx: p.index,
|
|
118
|
+
name: p.name,
|
|
119
|
+
addr: p.startBlock?.address,
|
|
120
|
+
size: p.blockCount?.size,
|
|
121
|
+
active: p.active === 1,
|
|
122
|
+
group: p.group
|
|
123
|
+
})),
|
|
124
|
+
group_mapping: logicalGroups.reduce((acc, group) => {
|
|
125
|
+
const partitions = group.partitions
|
|
126
|
+
.map((lp) => lp.name)
|
|
127
|
+
.filter((name) => name);
|
|
128
|
+
if (partitions.length > 0) {
|
|
129
|
+
acc[group.name] = partitions;
|
|
130
|
+
}
|
|
131
|
+
return acc;
|
|
132
|
+
}, {}),
|
|
133
|
+
summary: {
|
|
134
|
+
flash_used: partitionData.metadata?.flashUsed || null,
|
|
135
|
+
checksum: partitionData.metadata?.checksum || null,
|
|
136
|
+
magic: partitionData.metadata?.magic || null,
|
|
137
|
+
checksum_ok: partitionData.metadata?.checksumOk || false
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
// 使用官方 encode 函数转换为 TOON 格式
|
|
141
|
+
return encode(toonData, {
|
|
142
|
+
replacer: (key, value) => {
|
|
143
|
+
// 确保十六进制地址格式统一
|
|
144
|
+
if ((key === 'addr' || key === 'size') &&
|
|
145
|
+
typeof value === 'string' &&
|
|
146
|
+
value.startsWith('0x')) {
|
|
147
|
+
return value.toLowerCase();
|
|
148
|
+
}
|
|
149
|
+
return value;
|
|
150
|
+
},
|
|
151
|
+
indent: 0 // 紧凑格式,适合 AI 处理
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
export async function firmwarePrepareImages(params) {
|
|
155
|
+
const { cfg } = params;
|
|
156
|
+
const imagesRoot = params.imagesRoot && params.imagesRoot.length > 0 ? params.imagesRoot : 'images_agent';
|
|
157
|
+
const rootAbs = path.join(cfg.tftp.dir, imagesRoot);
|
|
158
|
+
let st;
|
|
159
|
+
try {
|
|
160
|
+
st = await fs.stat(rootAbs);
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
throw new Error(`firmware.prepare_images: imagesRoot directory not found: ${rootAbs} (${e?.message || String(e)})`);
|
|
164
|
+
}
|
|
165
|
+
if (!st.isDirectory()) {
|
|
166
|
+
throw new Error(`firmware.prepare_images: imagesRoot must be a directory, got ${rootAbs}`);
|
|
167
|
+
}
|
|
168
|
+
// 尝试解析 partition_layout.txt 文件并转换为 TOON 格式
|
|
169
|
+
const partitionLayoutFile = path.join(rootAbs, 'partition_layout.txt');
|
|
170
|
+
try {
|
|
171
|
+
const partitionData = await parsePartitionLayout(partitionLayoutFile);
|
|
172
|
+
return convertToToon(partitionData); // 只返回 TOON 格式的分区信息
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
// 如果解析失败,创建一个基本的 TOON 格式输出
|
|
176
|
+
return `partition_map[0]{idx,name,addr,size,active,group}:
|
|
177
|
+
group_mapping:
|
|
178
|
+
summary:
|
|
179
|
+
flash_used: null
|
|
180
|
+
checksum: null
|
|
181
|
+
magic: null
|
|
182
|
+
checksum_ok: false`;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { getDefaultDocsDir, getDocsDir } from '../board/docs.js';
|
|
5
|
+
function resolveFirmwareGuidePath() {
|
|
6
|
+
const docsDir = getDocsDir() || path.join(os.homedir(), '.config', 'embed_link');
|
|
7
|
+
const userPath = path.join(docsDir, 'firmware-upgrade.md');
|
|
8
|
+
const bundledPath = path.join(getDefaultDocsDir(), 'firmware-upgrade.md');
|
|
9
|
+
return { userPath, bundledPath };
|
|
10
|
+
}
|
|
11
|
+
export async function getFirmwareUserGuideMarkdown() {
|
|
12
|
+
const { userPath, bundledPath } = resolveFirmwareGuidePath();
|
|
13
|
+
try {
|
|
14
|
+
const buf = await fs.readFile(userPath, 'utf8');
|
|
15
|
+
if (!buf || !buf.trim().length) {
|
|
16
|
+
throw new Error('empty firmware_user_guide.md');
|
|
17
|
+
}
|
|
18
|
+
return buf;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
try {
|
|
22
|
+
const bundled = await fs.readFile(bundledPath, 'utf8');
|
|
23
|
+
if (!bundled || !bundled.trim().length) {
|
|
24
|
+
throw new Error('empty bundled firmware_user_guide.md');
|
|
25
|
+
}
|
|
26
|
+
await fs.mkdir(path.dirname(userPath), { recursive: true });
|
|
27
|
+
await fs.writeFile(userPath, bundled, 'utf8');
|
|
28
|
+
return bundled;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// 最后兜底:返回一个简短的 Markdown 提示,避免抛出异常阻塞调用
|
|
32
|
+
const text = [
|
|
33
|
+
'# Firmware Burn User Guide',
|
|
34
|
+
'',
|
|
35
|
+
'firmware-upgrade.md is missing; please create it under docs directory or resources/docs.',
|
|
36
|
+
'',
|
|
37
|
+
].join('\n');
|
|
38
|
+
await fs.mkdir(path.dirname(userPath), { recursive: true });
|
|
39
|
+
await fs.writeFile(userPath, text, 'utf8');
|
|
40
|
+
return text;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import fsSync from 'node:fs';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { loadConfig } from './config/index.js';
|
|
10
|
+
import { startGrpcServer } from './server/grpc.js';
|
|
11
|
+
import { startWebServer } from './server/web.js';
|
|
12
|
+
import { loadPlugins } from './plugins/loader.js';
|
|
13
|
+
import { cleanupTftpDir, startTftpServer, stopTftpServer } from './tftp/index.js';
|
|
14
|
+
import { createAgentPairingClient } from './ssh/paircheck.js';
|
|
15
|
+
function getLocalUserName() {
|
|
16
|
+
const envUser = process.env.USER || process.env.LOGNAME || process.env.USERNAME;
|
|
17
|
+
if (envUser && envUser.trim())
|
|
18
|
+
return envUser.replace(/[\\/]/g, '_');
|
|
19
|
+
try {
|
|
20
|
+
const info = os.userInfo();
|
|
21
|
+
if (info.username)
|
|
22
|
+
return info.username.replace(/[\\/]/g, '_');
|
|
23
|
+
}
|
|
24
|
+
catch { }
|
|
25
|
+
return 'unknown';
|
|
26
|
+
}
|
|
27
|
+
function getLocalAgentConfigPath() {
|
|
28
|
+
const home = os.homedir();
|
|
29
|
+
return path.join(home, '.config', 'embed_link', 'agent.json');
|
|
30
|
+
}
|
|
31
|
+
async function readExistingAgentJson() {
|
|
32
|
+
const p = getLocalAgentConfigPath();
|
|
33
|
+
try {
|
|
34
|
+
const buf = await fs.readFile(p, 'utf8');
|
|
35
|
+
return JSON.parse(buf);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function tryPairCheckExistingAgent(j) {
|
|
42
|
+
const host = j.endpoint?.grpc?.host || '127.0.0.1';
|
|
43
|
+
const port = j.endpoint?.grpc?.port;
|
|
44
|
+
const pairCode = j.agent?.pairCode;
|
|
45
|
+
if (!port || !pairCode)
|
|
46
|
+
return 'stale';
|
|
47
|
+
const addr = `${host}:${port}`;
|
|
48
|
+
try {
|
|
49
|
+
const client = createAgentPairingClient(addr);
|
|
50
|
+
const res = await client.pairCheck({ pairCode, clientId: 'embedlink-agent-bootstrap' });
|
|
51
|
+
if (res.accepted)
|
|
52
|
+
return 'accepted';
|
|
53
|
+
// pair-code mismatch 视为旧配置,可覆盖
|
|
54
|
+
return 'stale';
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return 'stale';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function writeLocalAgentConfig(cfg, identity) {
|
|
61
|
+
try {
|
|
62
|
+
const p = getLocalAgentConfigPath();
|
|
63
|
+
const dir = path.dirname(p);
|
|
64
|
+
await fs.mkdir(dir, { recursive: true });
|
|
65
|
+
const json = {
|
|
66
|
+
agent: {
|
|
67
|
+
id: identity.id,
|
|
68
|
+
version: identity.version,
|
|
69
|
+
pairCode: identity.pairCode,
|
|
70
|
+
startupTime: identity.startupTime,
|
|
71
|
+
originHost: identity.originHost,
|
|
72
|
+
originUser: identity.originUser,
|
|
73
|
+
description: cfg.agent?.description || '',
|
|
74
|
+
hostGrpcPort: cfg.grpc?.port,
|
|
75
|
+
},
|
|
76
|
+
endpoint: {
|
|
77
|
+
grpc: {
|
|
78
|
+
host: '127.0.0.1',
|
|
79
|
+
port: cfg.grpc?.port,
|
|
80
|
+
scheme: 'grpc',
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
transport: {
|
|
84
|
+
mode: 'local',
|
|
85
|
+
local: {
|
|
86
|
+
listenHost: cfg.grpc?.host || '0.0.0.0',
|
|
87
|
+
listenPort: cfg.grpc?.port,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
if (cfg.web?.port) {
|
|
92
|
+
json.endpoint = json.endpoint || {};
|
|
93
|
+
json.endpoint.web = {
|
|
94
|
+
host: cfg.web?.host || '127.0.0.1',
|
|
95
|
+
port: cfg.web?.port,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (cfg.tftp && cfg.tftp.server) {
|
|
99
|
+
const mode = cfg.tftp.server.mode || (cfg.tftp.server.enabled === false ? 'disabled' : 'builtin');
|
|
100
|
+
const enabled = cfg.tftp.server.enabled !== false && mode !== 'disabled';
|
|
101
|
+
json.services = {
|
|
102
|
+
...json.services,
|
|
103
|
+
tftp: {
|
|
104
|
+
enabled,
|
|
105
|
+
mode,
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
await fs.writeFile(p, JSON.stringify(json, null, 2), 'utf8');
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// 发现信息写入失败不影响 Agent 主流程
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// 打印所有环境变量及其功能说明
|
|
116
|
+
function printEnvironmentVariables() {
|
|
117
|
+
console.log('\n=== EmbedLink Agent 环境变量配置 ===');
|
|
118
|
+
// 定义环境变量及其功能说明
|
|
119
|
+
const envVars = [
|
|
120
|
+
{
|
|
121
|
+
name: 'EMBEDLINK_GRPC_PORT',
|
|
122
|
+
current: process.env.EMBEDLINK_GRPC_PORT,
|
|
123
|
+
description: 'gRPC服务器端口',
|
|
124
|
+
default: '10000',
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'EMBEDLINK_WEB_HOST',
|
|
128
|
+
current: process.env.EMBEDLINK_WEB_HOST,
|
|
129
|
+
description: 'Web服务器主机地址',
|
|
130
|
+
default: '0.0.0.0',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: 'EMBEDLINK_WEB_PORT',
|
|
134
|
+
current: process.env.EMBEDLINK_WEB_PORT,
|
|
135
|
+
description: 'Web服务器端口',
|
|
136
|
+
default: '8080',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'EMBEDLINK_TFTP_DIR',
|
|
140
|
+
current: process.env.EMBEDLINK_TFTP_DIR,
|
|
141
|
+
description: 'TFTP文件服务目录',
|
|
142
|
+
default: '~/.local/embed_link/agent_files',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'EMBEDLINK_TFTP_TTL_HOURS',
|
|
146
|
+
current: process.env.EMBEDLINK_TFTP_TTL_HOURS,
|
|
147
|
+
description: 'TFTP文件保存时间(小时)',
|
|
148
|
+
default: '24',
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'EMBEDLINK_TFTP_CLEANUP_INTERVAL_MIN',
|
|
152
|
+
current: process.env.EMBEDLINK_TFTP_CLEANUP_INTERVAL_MIN,
|
|
153
|
+
description: 'TFTP清理间隔(分钟)',
|
|
154
|
+
default: '60',
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: 'EMBEDLINK_BOARD_UART_PORT',
|
|
158
|
+
current: process.env.EMBEDLINK_BOARD_UART_PORT,
|
|
159
|
+
description: '板卡串口设备路径',
|
|
160
|
+
default: '自动检测',
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'EMBEDLINK_BOARD_UART_BAUD',
|
|
164
|
+
current: process.env.EMBEDLINK_BOARD_UART_BAUD,
|
|
165
|
+
description: '板卡串口波特率',
|
|
166
|
+
default: '115200',
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: 'EMBEDLINK_SSH_DEFAULT_KEY',
|
|
170
|
+
current: process.env.EMBEDLINK_SSH_DEFAULT_KEY,
|
|
171
|
+
description: 'SSH默认私钥路径',
|
|
172
|
+
default: '~/.ssh/id_rsa',
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: 'EMBEDLINK_AGENT_ID',
|
|
176
|
+
current: process.env.EMBEDLINK_AGENT_ID,
|
|
177
|
+
description: 'Agent实例ID',
|
|
178
|
+
default: '自动生成',
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
name: 'EMBEDLINK_AGENT_DISABLE_GATEKEEPER',
|
|
182
|
+
current: process.env.EMBEDLINK_AGENT_DISABLE_GATEKEEPER,
|
|
183
|
+
description: '禁用Agent互斥检查(测试用)',
|
|
184
|
+
default: 'false',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: 'EMBEDLINK_FILES_LOG_ENABLED',
|
|
188
|
+
current: process.env.EMBEDLINK_FILES_LOG_ENABLED,
|
|
189
|
+
description: '启用文件操作日志',
|
|
190
|
+
default: 'true',
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: 'EMBEDLINK_FILES_LOG_LEVEL',
|
|
194
|
+
current: process.env.EMBEDLINK_FILES_LOG_LEVEL,
|
|
195
|
+
description: '文件操作日志级别',
|
|
196
|
+
default: 'DEBUG',
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
name: 'EMBEDLINK_FILES_LOG_INCLUDE_METADATA',
|
|
200
|
+
current: process.env.EMBEDLINK_FILES_LOG_INCLUDE_METADATA,
|
|
201
|
+
description: '文件日志包含元数据',
|
|
202
|
+
default: 'true',
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: 'EMBEDLINK_FILES_LOG_MAX_SIZE',
|
|
206
|
+
current: process.env.EMBEDLINK_FILES_LOG_MAX_SIZE,
|
|
207
|
+
description: '文件日志最大大小',
|
|
208
|
+
default: '50MB',
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: 'EMBEDLINK_FILES_LOG_DETAILED_DEBUG',
|
|
212
|
+
current: process.env.EMBEDLINK_FILES_LOG_DETAILED_DEBUG,
|
|
213
|
+
description: '启用详细调试日志',
|
|
214
|
+
default: 'true',
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: 'EMBEDLINK_FILES_LOG_PATH_RESOLUTION',
|
|
218
|
+
current: process.env.EMBEDLINK_FILES_LOG_PATH_RESOLUTION,
|
|
219
|
+
description: '记录路径解析过程',
|
|
220
|
+
default: 'true',
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: 'EMBEDLINK_FILES_LOG_SECURITY_CHECKS',
|
|
224
|
+
current: process.env.EMBEDLINK_FILES_LOG_SECURITY_CHECKS,
|
|
225
|
+
description: '记录安全检查过程',
|
|
226
|
+
default: 'true',
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: 'EMBEDLINK_FILES_LOG_PERFORMANCE_METRICS',
|
|
230
|
+
current: process.env.EMBEDLINK_FILES_LOG_PERFORMANCE_METRICS,
|
|
231
|
+
description: '记录性能指标',
|
|
232
|
+
default: 'true',
|
|
233
|
+
},
|
|
234
|
+
// Host端环境变量(用于调试)
|
|
235
|
+
{
|
|
236
|
+
name: 'EMBEDLINK_HOST_DEBUG',
|
|
237
|
+
current: process.env.EMBEDLINK_HOST_DEBUG,
|
|
238
|
+
description: 'Host端调试模式',
|
|
239
|
+
default: 'false',
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
name: 'EMBEDLINK_DEBUG',
|
|
243
|
+
current: process.env.EMBEDLINK_DEBUG,
|
|
244
|
+
description: '全局调试模式',
|
|
245
|
+
default: 'false',
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
name: 'EMBEDLINK_AGENT_ADDR',
|
|
249
|
+
current: process.env.EMBEDLINK_AGENT_ADDR,
|
|
250
|
+
description: 'Agent连接地址(Host端使用)',
|
|
251
|
+
default: '127.0.0.1:10000',
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
name: 'EMBEDLINK_LOG_LEVEL',
|
|
255
|
+
current: process.env.EMBEDLINK_LOG_LEVEL,
|
|
256
|
+
description: '日志级别',
|
|
257
|
+
default: 'DEBUG',
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
name: 'EMBEDLINK_LOG_DIR',
|
|
261
|
+
current: process.env.EMBEDLINK_LOG_DIR,
|
|
262
|
+
description: '日志目录',
|
|
263
|
+
default: '系统默认',
|
|
264
|
+
},
|
|
265
|
+
];
|
|
266
|
+
// 找到最长的变量名长度,用于对齐
|
|
267
|
+
const maxNameLength = Math.max(...envVars.map((v) => v.name.length));
|
|
268
|
+
console.log(`变量名${' '.repeat(maxNameLength - 4)} 当前值${' '.repeat(15)} 默认值 功能描述`);
|
|
269
|
+
console.log('─'.repeat(maxNameLength + 50));
|
|
270
|
+
envVars.forEach((envVar) => {
|
|
271
|
+
const namePad = envVar.name.padEnd(maxNameLength);
|
|
272
|
+
const current = envVar.current || '(未设置)';
|
|
273
|
+
const currentPad = current.padEnd(15);
|
|
274
|
+
const defaultPad = envVar.default.padEnd(10);
|
|
275
|
+
console.log(`${namePad} ${currentPad} ${defaultPad} ${envVar.description}`);
|
|
276
|
+
});
|
|
277
|
+
console.log('='.repeat(maxNameLength + 50));
|
|
278
|
+
console.log('');
|
|
279
|
+
}
|
|
280
|
+
function parseCliArgs(argv) {
|
|
281
|
+
const args = argv.slice(2);
|
|
282
|
+
let debug = false;
|
|
283
|
+
let noOpen = false;
|
|
284
|
+
for (const a of args) {
|
|
285
|
+
if (a === '-d')
|
|
286
|
+
debug = true;
|
|
287
|
+
else if (a === '--no-open')
|
|
288
|
+
noOpen = true;
|
|
289
|
+
}
|
|
290
|
+
return { debug, noOpen };
|
|
291
|
+
}
|
|
292
|
+
function openBrowser(url) {
|
|
293
|
+
const platform = process.platform;
|
|
294
|
+
let cmd;
|
|
295
|
+
let cmdArgs;
|
|
296
|
+
if (platform === 'win32') {
|
|
297
|
+
cmd = 'cmd';
|
|
298
|
+
cmdArgs = ['/c', 'start', '', url];
|
|
299
|
+
}
|
|
300
|
+
else if (platform === 'darwin') {
|
|
301
|
+
cmd = 'open';
|
|
302
|
+
cmdArgs = [url];
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
cmd = 'xdg-open';
|
|
306
|
+
cmdArgs = [url];
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
const child = spawn(cmd, cmdArgs, { detached: true, stdio: 'ignore' });
|
|
310
|
+
child.unref();
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// ignore
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function getDefaultWebUiStaticDir() {
|
|
317
|
+
// dist/index.js lives in dist/, we bundle UI at dist/ui
|
|
318
|
+
const self = fileURLToPath(import.meta.url);
|
|
319
|
+
const distDir = path.dirname(self);
|
|
320
|
+
return path.join(distDir, 'ui');
|
|
321
|
+
}
|
|
322
|
+
async function main() {
|
|
323
|
+
const cli = parseCliArgs(process.argv);
|
|
324
|
+
// 默认少日志;-d 才输出详细信息
|
|
325
|
+
if (cli.debug) {
|
|
326
|
+
printEnvironmentVariables();
|
|
327
|
+
}
|
|
328
|
+
const cfg = await loadConfig();
|
|
329
|
+
const agentId = (cfg.agent && cfg.agent.id) || 'agent';
|
|
330
|
+
const originHost = os.hostname();
|
|
331
|
+
const originUser = getLocalUserName();
|
|
332
|
+
// Gatekeeper: reuse or validate existing agent.json(可通过环境变量禁用,仅用于测试/特殊场景)
|
|
333
|
+
const skipGatekeeper = process.env.EMBEDLINK_AGENT_DISABLE_GATEKEEPER === '1' ||
|
|
334
|
+
process.env.EMBEDLINK_AGENT_DISABLE_GATEKEEPER === 'true';
|
|
335
|
+
let reusePairCode;
|
|
336
|
+
if (!skipGatekeeper) {
|
|
337
|
+
const existing = await readExistingAgentJson();
|
|
338
|
+
if (existing && existing.agent?.pairCode && existing.endpoint?.grpc?.port) {
|
|
339
|
+
const state = await tryPairCheckExistingAgent(existing);
|
|
340
|
+
if (state === 'accepted') {
|
|
341
|
+
// 已有活跃 Agent 实例在使用该配置,拒绝启动新的实例
|
|
342
|
+
console.error(`embedlink-agent: detected existing active Agent (id=${existing.agent?.id || agentId}), aborting startup.`);
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
reusePairCode = existing.agent.pairCode;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const pairCode = reusePairCode || randomUUID();
|
|
351
|
+
const startupTime = new Date().toISOString();
|
|
352
|
+
const identity = {
|
|
353
|
+
id: agentId,
|
|
354
|
+
version: '0.1.0',
|
|
355
|
+
pairCode,
|
|
356
|
+
startupTime,
|
|
357
|
+
originHost,
|
|
358
|
+
originUser,
|
|
359
|
+
};
|
|
360
|
+
// 根据配置启动 TFTP 服务器(builtin / external / disabled)
|
|
361
|
+
await startTftpServer(cfg);
|
|
362
|
+
const staticDir = cfg.web.staticDir || getDefaultWebUiStaticDir();
|
|
363
|
+
if (!fsSync.existsSync(staticDir)) {
|
|
364
|
+
console.error(`embedlink-agent: web UI assets not found at: ${staticDir}`);
|
|
365
|
+
console.error(`Hint: if you're developing from source, set web.staticDir explicitly.`);
|
|
366
|
+
process.exit(1);
|
|
367
|
+
}
|
|
368
|
+
const web = await startWebServer({
|
|
369
|
+
host: cfg.web.host,
|
|
370
|
+
port: cfg.web.port,
|
|
371
|
+
staticDir,
|
|
372
|
+
grpcPort: cfg.grpc.port,
|
|
373
|
+
agentId,
|
|
374
|
+
pairCode,
|
|
375
|
+
originHost,
|
|
376
|
+
originUser,
|
|
377
|
+
version: identity.version,
|
|
378
|
+
startupTime,
|
|
379
|
+
agentConfig: cfg,
|
|
380
|
+
});
|
|
381
|
+
const grpc = await startGrpcServer(cfg, identity);
|
|
382
|
+
await writeLocalAgentConfig(cfg, identity);
|
|
383
|
+
const open = !cli.noOpen;
|
|
384
|
+
if (open && cfg.web?.port) {
|
|
385
|
+
const port = cfg.web.port;
|
|
386
|
+
const hostForBrowser = '127.0.0.1';
|
|
387
|
+
const url = `http://${hostForBrowser}:${port}/`;
|
|
388
|
+
if (cli.debug) {
|
|
389
|
+
console.log(`Web UI: ${url}`);
|
|
390
|
+
console.log(`Hint: use --no-open to disable auto-open.`);
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
console.log(`Web UI: ${url} (use --no-open to disable auto-open)`);
|
|
394
|
+
}
|
|
395
|
+
openBrowser(url);
|
|
396
|
+
}
|
|
397
|
+
else if (cli.debug) {
|
|
398
|
+
console.log('Web UI auto-open disabled.');
|
|
399
|
+
}
|
|
400
|
+
// 周期性清理 TFTP 目录中过期镜像文件
|
|
401
|
+
const ttlHours = cfg.tftp.ttlHours ?? 24;
|
|
402
|
+
const intervalMinutes = cfg.tftp.cleanupIntervalMinutes ?? 60;
|
|
403
|
+
if (ttlHours > 0 && intervalMinutes > 0) {
|
|
404
|
+
const ttlMs = ttlHours * 60 * 60 * 1000;
|
|
405
|
+
const intervalMs = intervalMinutes * 60 * 1000;
|
|
406
|
+
setInterval(() => {
|
|
407
|
+
cleanupTftpDir(cfg.tftp.dir, ttlMs).catch(() => { });
|
|
408
|
+
}, intervalMs).unref?.();
|
|
409
|
+
}
|
|
410
|
+
await loadPlugins('plugins');
|
|
411
|
+
// 优雅关闭处理函数
|
|
412
|
+
async function gracefulShutdown() {
|
|
413
|
+
try {
|
|
414
|
+
// 先关闭gRPC服务器,停止接受新请求
|
|
415
|
+
await new Promise((resolve) => {
|
|
416
|
+
grpc.tryShutdown(() => resolve());
|
|
417
|
+
});
|
|
418
|
+
// 关闭Web服务器
|
|
419
|
+
await new Promise((resolve) => {
|
|
420
|
+
web.server.close(() => resolve());
|
|
421
|
+
});
|
|
422
|
+
// 停止TFTP服务器
|
|
423
|
+
await stopTftpServer().catch(() => { });
|
|
424
|
+
process.exit(0);
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
console.error('Error during shutdown:', err);
|
|
428
|
+
process.exit(1);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// 强制退出超时保护
|
|
432
|
+
let shutdownTimeout;
|
|
433
|
+
function scheduleForceExit() {
|
|
434
|
+
shutdownTimeout = setTimeout(() => process.exit(1), 5000);
|
|
435
|
+
}
|
|
436
|
+
// 信号处理器
|
|
437
|
+
function handleShutdown(signal) {
|
|
438
|
+
if (shutdownTimeout)
|
|
439
|
+
clearTimeout(shutdownTimeout);
|
|
440
|
+
scheduleForceExit();
|
|
441
|
+
gracefulShutdown();
|
|
442
|
+
}
|
|
443
|
+
process.on('SIGINT', () => handleShutdown('SIGINT'));
|
|
444
|
+
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
|
|
445
|
+
}
|
|
446
|
+
main().catch((e) => {
|
|
447
|
+
console.error('Agent failed:', e);
|
|
448
|
+
process.exit(1);
|
|
449
|
+
});
|