@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.
Files changed (53) hide show
  1. package/README.md +107 -0
  2. package/dist/.platform +1 -0
  3. package/dist/board/docs.js +59 -0
  4. package/dist/board/notes.js +11 -0
  5. package/dist/board_uart/history.js +81 -0
  6. package/dist/board_uart/index.js +66 -0
  7. package/dist/board_uart/manager.js +313 -0
  8. package/dist/board_uart/resource.js +578 -0
  9. package/dist/board_uart/sessions.js +559 -0
  10. package/dist/config/index.js +341 -0
  11. package/dist/core/activity.js +7 -0
  12. package/dist/core/errors.js +45 -0
  13. package/dist/core/log_stream.js +26 -0
  14. package/dist/files/__tests__/files_manager.test.js +209 -0
  15. package/dist/files/artifact_manager.js +68 -0
  16. package/dist/files/file_operation_logger.js +271 -0
  17. package/dist/files/files_manager.js +511 -0
  18. package/dist/files/index.js +87 -0
  19. package/dist/files/types.js +5 -0
  20. package/dist/firmware/burn_recover.js +733 -0
  21. package/dist/firmware/prepare_images.js +184 -0
  22. package/dist/firmware/user_guide.js +43 -0
  23. package/dist/index.js +449 -0
  24. package/dist/logger.js +245 -0
  25. package/dist/macro/index.js +241 -0
  26. package/dist/macro/runner.js +168 -0
  27. package/dist/nfs/index.js +105 -0
  28. package/dist/plugins/loader.js +30 -0
  29. package/dist/proto/agent.proto +473 -0
  30. package/dist/resources/docs/board-interaction.md +115 -0
  31. package/dist/resources/docs/firmware-upgrade.md +404 -0
  32. package/dist/resources/docs/nfs-mount-guide.md +78 -0
  33. package/dist/resources/docs/tftp-transfer-guide.md +81 -0
  34. package/dist/secrets/index.js +9 -0
  35. package/dist/server/grpc.js +1069 -0
  36. package/dist/server/web.js +2284 -0
  37. package/dist/ssh/adapter.js +126 -0
  38. package/dist/ssh/candidates.js +85 -0
  39. package/dist/ssh/index.js +3 -0
  40. package/dist/ssh/paircheck.js +35 -0
  41. package/dist/ssh/tunnel.js +111 -0
  42. package/dist/tftp/client.js +345 -0
  43. package/dist/tftp/index.js +284 -0
  44. package/dist/tftp/server.js +731 -0
  45. package/dist/uboot/index.js +45 -0
  46. package/dist/ui/assets/index-BlnLVmbt.js +374 -0
  47. package/dist/ui/assets/index-xMbarYXA.css +32 -0
  48. package/dist/ui/index.html +21 -0
  49. package/dist/utils/network.js +150 -0
  50. package/dist/utils/platform.js +83 -0
  51. package/dist/utils/port-check.js +153 -0
  52. package/dist/utils/user-prompt.js +139 -0
  53. package/package.json +64 -0
@@ -0,0 +1,341 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import YAML from 'yaml';
5
+ import { DefaultTimeouts } from '../core/errors.js';
6
+ import { LogLevel } from '../logger.js';
7
+ const HOME_CONFIG_PATH = path.join(os.homedir(), '.config', 'embed_link', 'config.json');
8
+ function defaultTftpDir() {
9
+ return getDefaultAgentFilesDir();
10
+ }
11
+ function defaultShareUsername() {
12
+ const envUser = process.env.USER || process.env.LOGNAME || process.env.USERNAME;
13
+ if (envUser && envUser.trim())
14
+ return envUser.replace(/[\\/]/g, '_');
15
+ try {
16
+ const info = os.userInfo();
17
+ if (info.username && info.username.trim())
18
+ return info.username.replace(/[\\/]/g, '_');
19
+ }
20
+ catch { }
21
+ return 'user';
22
+ }
23
+ function getDefaultAgentFilesDir() {
24
+ return path.join(os.homedir(), '.local', 'embed_link', 'agent_files');
25
+ }
26
+ export const DEFAULT_CONFIG = {
27
+ grpc: { host: '0.0.0.0', port: 10000 },
28
+ web: { host: '0.0.0.0', port: 8080 },
29
+ boardUart: {
30
+ mock: false,
31
+ port: '',
32
+ baud: 115200,
33
+ },
34
+ network: {},
35
+ tftp: {
36
+ dir: getDefaultAgentFilesDir(),
37
+ server: {
38
+ enabled: true,
39
+ mode: 'builtin',
40
+ host: '0.0.0.0',
41
+ port: 69,
42
+ },
43
+ },
44
+ nfs: {
45
+ enabled: true,
46
+ localPath: getDefaultAgentFilesDir(),
47
+ exportName: '/shared',
48
+ description: 'Default NFS export',
49
+ username: defaultShareUsername(),
50
+ password: '',
51
+ },
52
+ flash: {},
53
+ ssh: {
54
+ enablePasswordFallback: true,
55
+ defaultKeyPath: '',
56
+ },
57
+ timeouts: DefaultTimeouts,
58
+ files: {
59
+ rootDir: getDefaultAgentFilesDir(),
60
+ maxFileSize: 100 * 1024 * 1024, // 100MB
61
+ allowedPaths: [getDefaultAgentFilesDir()],
62
+ maxTotalBytes: 5 * 1024 * 1024 * 1024, // 5GB
63
+ maxTotalFiles: 50000,
64
+ skipHiddenByDefault: false,
65
+ },
66
+ logging: {
67
+ files: {
68
+ enabled: true,
69
+ level: LogLevel.DEBUG,
70
+ includeMetadata: true,
71
+ maxLogSize: 50 * 1024 * 1024, // 50MB
72
+ detailedDebug: true,
73
+ pathResolution: true,
74
+ securityChecks: true,
75
+ performanceMetrics: true,
76
+ },
77
+ },
78
+ };
79
+ let lastConfigPath = null;
80
+ function defaultKey() {
81
+ const home = os.homedir();
82
+ return path.join(home, '.ssh', 'id_rsa');
83
+ }
84
+ function normalize(p) {
85
+ return path.normalize(p);
86
+ }
87
+ function ensureTftpServerConfig(cfg) {
88
+ if (!cfg.tftp) {
89
+ cfg.tftp = {
90
+ dir: defaultTftpDir(),
91
+ ttlHours: 24,
92
+ cleanupIntervalMinutes: 60,
93
+ server: {
94
+ enabled: true,
95
+ mode: 'builtin',
96
+ host: '0.0.0.0',
97
+ port: 69,
98
+ blockSize: 1024, // 1KB块大小(原512字节)
99
+ timeout: 15000, // 15秒超时(原5000ms)
100
+ retryCount: 8, // 8次重试(原3次)
101
+ },
102
+ };
103
+ return;
104
+ }
105
+ if (!cfg.tftp.server) {
106
+ cfg.tftp.server = {
107
+ enabled: true,
108
+ mode: 'builtin',
109
+ host: '0.0.0.0',
110
+ port: 69,
111
+ blockSize: 512, // 默认 512 字节
112
+ timeout: 5000, // 默认 5 秒超时
113
+ retryCount: 3, // 默认 3 次重试
114
+ };
115
+ }
116
+ else {
117
+ if (typeof cfg.tftp.server.enabled !== 'boolean')
118
+ cfg.tftp.server.enabled = true;
119
+ if (!cfg.tftp.server.mode) {
120
+ cfg.tftp.server.mode = cfg.tftp.server.enabled === false ? 'disabled' : 'builtin';
121
+ }
122
+ if (cfg.tftp.server.mode === 'builtin') {
123
+ if (!cfg.tftp.server.host)
124
+ cfg.tftp.server.host = '0.0.0.0';
125
+ if (!cfg.tftp.server.port)
126
+ cfg.tftp.server.port = 69;
127
+ if (!cfg.tftp.server.blockSize)
128
+ cfg.tftp.server.blockSize = 512; // 默认 512 字节
129
+ if (!cfg.tftp.server.timeout)
130
+ cfg.tftp.server.timeout = 5000; // 默认 5 秒超时
131
+ if (!cfg.tftp.server.retryCount)
132
+ cfg.tftp.server.retryCount = 3; // 默认 3 次重试
133
+ }
134
+ if (cfg.tftp.server.mode === 'external') {
135
+ if (!cfg.tftp.server.externalPort)
136
+ cfg.tftp.server.externalPort = 69;
137
+ }
138
+ if (cfg.tftp.server.mode === 'disabled') {
139
+ cfg.tftp.server.enabled = false;
140
+ }
141
+ }
142
+ }
143
+ function ensureNfsConfig(cfg) {
144
+ if (!cfg.nfs) {
145
+ cfg.nfs = {
146
+ enabled: true,
147
+ localPath: cfg.tftp?.dir || getDefaultAgentFilesDir(),
148
+ exportName: '/shared',
149
+ description: 'Default NFS export',
150
+ username: defaultShareUsername(),
151
+ password: '',
152
+ };
153
+ return;
154
+ }
155
+ if (typeof cfg.nfs.enabled !== 'boolean')
156
+ cfg.nfs.enabled = true;
157
+ if (!cfg.nfs.localPath)
158
+ cfg.nfs.localPath = cfg.tftp?.dir || getDefaultAgentFilesDir();
159
+ if (!cfg.nfs.exportName)
160
+ cfg.nfs.exportName = '/shared';
161
+ if (!cfg.nfs.description)
162
+ cfg.nfs.description = 'Default NFS export';
163
+ if (!cfg.nfs.username || !cfg.nfs.username.trim())
164
+ cfg.nfs.username = defaultShareUsername();
165
+ if (typeof cfg.nfs.password !== 'string')
166
+ cfg.nfs.password = '';
167
+ }
168
+ function ensureNetworkConfig(cfg) {
169
+ if (!cfg.network) {
170
+ cfg.network = {};
171
+ return;
172
+ }
173
+ if (typeof cfg.network.preferredInterface !== 'string')
174
+ cfg.network.preferredInterface = '';
175
+ if (typeof cfg.network.preferredIp !== 'string')
176
+ cfg.network.preferredIp = '';
177
+ }
178
+ export async function loadConfig() {
179
+ const cwd = process.cwd();
180
+ const candidates = [
181
+ HOME_CONFIG_PATH,
182
+ path.join(cwd, 'agent.config.yaml'),
183
+ path.join(cwd, 'agent.config.yml'),
184
+ path.join(cwd, 'agent.config.json'),
185
+ ];
186
+ let cfg = DEFAULT_CONFIG;
187
+ for (const p of candidates) {
188
+ try {
189
+ const raw = await fs.readFile(p, 'utf8');
190
+ const y = p.endsWith('.json') ? JSON.parse(raw) : YAML.parse(raw);
191
+ cfg = { ...cfg, ...y };
192
+ lastConfigPath = p;
193
+ break;
194
+ }
195
+ catch { }
196
+ }
197
+ // ENV overrides
198
+ if (process.env.EMBEDLINK_GRPC_PORT)
199
+ cfg.grpc.port = Number(process.env.EMBEDLINK_GRPC_PORT);
200
+ if (process.env.EMBEDLINK_WEB_HOST)
201
+ cfg.web.host = process.env.EMBEDLINK_WEB_HOST;
202
+ if (process.env.EMBEDLINK_WEB_PORT)
203
+ cfg.web.port = Number(process.env.EMBEDLINK_WEB_PORT);
204
+ if (process.env.EMBEDLINK_TFTP_DIR)
205
+ cfg.tftp.dir = process.env.EMBEDLINK_TFTP_DIR;
206
+ if (process.env.EMBEDLINK_TFTP_TTL_HOURS)
207
+ cfg.tftp.ttlHours = Number(process.env.EMBEDLINK_TFTP_TTL_HOURS);
208
+ if (process.env.EMBEDLINK_TFTP_CLEANUP_INTERVAL_MIN)
209
+ cfg.tftp.cleanupIntervalMinutes = Number(process.env.EMBEDLINK_TFTP_CLEANUP_INTERVAL_MIN);
210
+ ensureTftpServerConfig(cfg);
211
+ // Board UART defaults / overrides
212
+ if (process.env.EMBEDLINK_BOARD_UART_PORT)
213
+ cfg.boardUart.port = process.env.EMBEDLINK_BOARD_UART_PORT;
214
+ if (process.env.EMBEDLINK_BOARD_UART_BAUD)
215
+ cfg.boardUart.baud = Number(process.env.EMBEDLINK_BOARD_UART_BAUD);
216
+ cfg.tftp.dir = normalize(cfg.tftp.dir);
217
+ ensureNfsConfig(cfg);
218
+ cfg.nfs.localPath = normalize(cfg.nfs.localPath);
219
+ // SSH 默认密钥路径:始终基于当前用户主目录计算,可通过环境变量覆盖
220
+ const envDefaultKey = (process.env.EMBEDLINK_SSH_DEFAULT_KEY || '').trim();
221
+ if (!cfg.ssh) {
222
+ cfg.ssh = {
223
+ enablePasswordFallback: true,
224
+ defaultKeyPath: envDefaultKey || defaultKey(),
225
+ };
226
+ }
227
+ else {
228
+ cfg.ssh.enablePasswordFallback =
229
+ typeof cfg.ssh.enablePasswordFallback === 'boolean' ? cfg.ssh.enablePasswordFallback : true;
230
+ cfg.ssh.defaultKeyPath = envDefaultKey || defaultKey();
231
+ }
232
+ if (!cfg.agent)
233
+ cfg.agent = { id: defaultAgentId() };
234
+ if (!cfg.agent.id)
235
+ cfg.agent.id = makeDefaultAgentId(os.hostname?.() || 'host', cfg.grpc.port);
236
+ if (process.env.EMBEDLINK_AGENT_ID)
237
+ cfg.agent.id = process.env.EMBEDLINK_AGENT_ID;
238
+ ensureNetworkConfig(cfg);
239
+ // 文件操作日志配置的环境变量支持
240
+ if (!cfg.logging)
241
+ cfg.logging = {};
242
+ if (!cfg.logging.files)
243
+ cfg.logging.files = DEFAULT_CONFIG.logging.files;
244
+ // 环境变量覆盖文件操作日志配置
245
+ if (process.env.EMBEDLINK_FILES_LOG_ENABLED !== undefined && cfg.logging.files) {
246
+ cfg.logging.files.enabled =
247
+ process.env.EMBEDLINK_FILES_LOG_ENABLED === '1' ||
248
+ process.env.EMBEDLINK_FILES_LOG_ENABLED === 'true';
249
+ }
250
+ if (process.env.EMBEDLINK_FILES_LOG_LEVEL && cfg.logging.files) {
251
+ cfg.logging.files.level = process.env.EMBEDLINK_FILES_LOG_LEVEL;
252
+ }
253
+ if (process.env.EMBEDLINK_FILES_LOG_INCLUDE_METADATA !== undefined && cfg.logging.files) {
254
+ cfg.logging.files.includeMetadata =
255
+ process.env.EMBEDLINK_FILES_LOG_INCLUDE_METADATA === '1' ||
256
+ process.env.EMBEDLINK_FILES_LOG_INCLUDE_METADATA === 'true';
257
+ }
258
+ if (process.env.EMBEDLINK_FILES_LOG_MAX_SIZE && cfg.logging.files) {
259
+ const sizeStr = process.env.EMBEDLINK_FILES_LOG_MAX_SIZE;
260
+ const units = {
261
+ B: 1,
262
+ KB: 1024,
263
+ MB: 1024 * 1024,
264
+ GB: 1024 * 1024 * 1024,
265
+ };
266
+ const match = sizeStr.match(/^(\d+)(B|KB|MB|GB)?$/i);
267
+ if (match) {
268
+ const value = parseInt(match[1]);
269
+ const unit = match[2]?.toUpperCase() || 'B';
270
+ cfg.logging.files.maxLogSize = value * (units[unit] || 1);
271
+ }
272
+ }
273
+ if (process.env.EMBEDLINK_FILES_LOG_DETAILED_DEBUG !== undefined && cfg.logging.files) {
274
+ cfg.logging.files.detailedDebug =
275
+ process.env.EMBEDLINK_FILES_LOG_DETAILED_DEBUG === '1' ||
276
+ process.env.EMBEDLINK_FILES_LOG_DETAILED_DEBUG === 'true';
277
+ }
278
+ if (process.env.EMBEDLINK_FILES_LOG_PATH_RESOLUTION !== undefined && cfg.logging.files) {
279
+ cfg.logging.files.pathResolution =
280
+ process.env.EMBEDLINK_FILES_LOG_PATH_RESOLUTION === '1' ||
281
+ process.env.EMBEDLINK_FILES_LOG_PATH_RESOLUTION === 'true';
282
+ }
283
+ if (process.env.EMBEDLINK_FILES_LOG_SECURITY_CHECKS !== undefined && cfg.logging.files) {
284
+ cfg.logging.files.securityChecks =
285
+ process.env.EMBEDLINK_FILES_LOG_SECURITY_CHECKS === '1' ||
286
+ process.env.EMBEDLINK_FILES_LOG_SECURITY_CHECKS === 'true';
287
+ }
288
+ if (process.env.EMBEDLINK_FILES_LOG_PERFORMANCE_METRICS !== undefined && cfg.logging.files) {
289
+ cfg.logging.files.performanceMetrics =
290
+ process.env.EMBEDLINK_FILES_LOG_PERFORMANCE_METRICS === '1' ||
291
+ process.env.EMBEDLINK_FILES_LOG_PERFORMANCE_METRICS === 'true';
292
+ }
293
+ // 统一文件存储路径:确保 Files 系统使用与 TFTP/NFS 相同的配置路径
294
+ // 这符合 Settings 页面"Local Artifacts Path"的设计意图
295
+ if (!cfg.files) {
296
+ cfg.files = DEFAULT_CONFIG.files;
297
+ }
298
+ // 如果用户在 Settings 页面配置了统一的 artifacts 路径(通过 tftp.dir),
299
+ // 则 Files 系统也使用相同路径,实现统一的文件存储管理
300
+ if (cfg.tftp && cfg.tftp.dir) {
301
+ cfg.files.rootDir = cfg.tftp.dir;
302
+ // 同时更新 allowedPaths,确保 Files 系统可以访问该路径
303
+ if (!cfg.files.allowedPaths || cfg.files.allowedPaths.length === 0) {
304
+ cfg.files.allowedPaths = [cfg.tftp.dir];
305
+ }
306
+ else if (!cfg.files.allowedPaths.includes(cfg.tftp.dir)) {
307
+ // 将统一路径添加到 allowedPaths 的开头,优先使用
308
+ cfg.files.allowedPaths = [
309
+ cfg.tftp.dir,
310
+ ...cfg.files.allowedPaths.filter((p) => p !== cfg.tftp.dir),
311
+ ];
312
+ }
313
+ }
314
+ if (!lastConfigPath)
315
+ lastConfigPath = HOME_CONFIG_PATH;
316
+ return cfg;
317
+ }
318
+ export function getConfigPath() {
319
+ return lastConfigPath || HOME_CONFIG_PATH;
320
+ }
321
+ export async function saveConfig(cfg, targetPath) {
322
+ const outPath = targetPath || getConfigPath();
323
+ const content = outPath.endsWith('.json') || outPath.endsWith('.json5')
324
+ ? JSON.stringify(cfg, null, 2)
325
+ : YAML.stringify(cfg);
326
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
327
+ await fs.writeFile(outPath, content, 'utf8');
328
+ lastConfigPath = outPath;
329
+ return outPath;
330
+ }
331
+ function defaultAgentId() {
332
+ const seed = `${process.cwd()}|${process.pid}|${Date.now()}`;
333
+ let h = 5381;
334
+ for (let i = 0; i < seed.length; i++)
335
+ h = (h << 5) + h + seed.charCodeAt(i);
336
+ return `agent-${(h >>> 0).toString(16)}`;
337
+ }
338
+ function makeDefaultAgentId(hostname, port) {
339
+ const raw = `agent-${hostname}-${port}`;
340
+ return raw.replace(/[^A-Za-z0-9._-]/g, '-');
341
+ }
@@ -0,0 +1,7 @@
1
+ export let lastGrpcActivity = 0;
2
+ export function markGrpcActivity() {
3
+ lastGrpcActivity = Date.now();
4
+ }
5
+ export function getGrpcLastActivity() {
6
+ return lastGrpcActivity;
7
+ }
@@ -0,0 +1,45 @@
1
+ export const ErrorCodes = {
2
+ EL_AGENT_UNAVAILABLE: 'EL_AGENT_UNAVAILABLE',
3
+ EL_PROCEDURE_TIMEOUT: 'EL_PROCEDURE_TIMEOUT',
4
+ EL_INVALID_PARAMS: 'EL_INVALID_PARAMS',
5
+ EL_BOARD_UART_OPEN_FAILED: 'EL_BOARD_UART_OPEN_FAILED',
6
+ EL_BOARD_UART_WRITE_FAILED: 'EL_BOARD_UART_WRITE_FAILED',
7
+ EL_BOARD_UART_READ_IN_PROGRESS: 'EL_BOARD_UART_READ_IN_PROGRESS',
8
+ EL_BOARD_UART_READ_TIMEOUT: 'EL_BOARD_UART_READ_TIMEOUT',
9
+ EL_BOARD_UART_MODULE_MISSING: 'EL_BOARD_UART_MODULE_MISSING',
10
+ EL_BOARD_UART_NO_DEVICE: 'EL_BOARD_UART_NO_DEVICE',
11
+ EL_BOARD_UART_DISABLED: 'EL_BOARD_UART_DISABLED',
12
+ EL_BOARD_UART_SESSION_NOT_FOUND: 'EL_BOARD_UART_SESSION_NOT_FOUND',
13
+ EL_BOARD_UART_SESSION_TEMPORARILY_UNAVAILABLE: 'EL_BOARD_UART_SESSION_TEMPORARILY_UNAVAILABLE',
14
+ EL_BOARD_UART_READONLY_SESSION: 'EL_BOARD_UART_READONLY_SESSION',
15
+ EL_BOARD_UART_SUSPENDED: 'EL_BOARD_UART_SUSPENDED',
16
+ EL_TFTP_UPLOAD_FAILED: 'EL_TFTP_UPLOAD_FAILED',
17
+ EL_TFTP_VERIFY_FAILED: 'EL_TFTP_VERIFY_FAILED',
18
+ EL_TFTP_DISABLED: 'EL_TFTP_DISABLED',
19
+ EL_TFTP_SERVER_ALREADY_RUNNING: 'EL_TFTP_SERVER_ALREADY_RUNNING',
20
+ EL_TFTP_SERVER_START_FAILED: 'EL_TFTP_SERVER_START_FAILED',
21
+ EL_UBOOT_BREAK_TIMEOUT: 'EL_UBOOT_BREAK_TIMEOUT',
22
+ EL_UBOOT_CMD_FAILED: 'EL_UBOOT_CMD_FAILED',
23
+ EL_FLASH_WRITE_FAILED: 'EL_FLASH_WRITE_FAILED',
24
+ EL_FLASH_VERIFY_FAILED: 'EL_FLASH_VERIFY_FAILED',
25
+ EL_REBOOT_TIMEOUT: 'EL_REBOOT_TIMEOUT',
26
+ EL_SSH_CONNECT_FAILED: 'EL_SSH_CONNECT_FAILED',
27
+ EL_SSH_TUNNEL_BROKEN: 'EL_SSH_TUNNEL_BROKEN',
28
+ EL_NFS_DISABLED: 'EL_NFS_DISABLED',
29
+ };
30
+ export const DefaultTimeouts = {
31
+ boardUart: { open: 3000, read: 5000, write: 5000 },
32
+ tftp: { upload: 120000 },
33
+ uboot: { break: 5000, cmd: 10000 },
34
+ flash: { write: 300000 },
35
+ reboot: { wait: 60000 },
36
+ ssh: { connect: 15000 },
37
+ };
38
+ export function error(code, message, data) {
39
+ const fullMessage = `[${code}] ${message}`;
40
+ const e = new Error(fullMessage);
41
+ e.code = code;
42
+ if (data)
43
+ e.data = data;
44
+ return e;
45
+ }
@@ -0,0 +1,26 @@
1
+ const MAX_LOG_BUFFER = 1000;
2
+ const agentLogBuf = [];
3
+ const hostLogBuf = [];
4
+ const listeners = new Set();
5
+ function pushBounded(buf, entry) {
6
+ buf.push(entry);
7
+ if (buf.length > MAX_LOG_BUFFER)
8
+ buf.splice(0, buf.length - MAX_LOG_BUFFER);
9
+ }
10
+ export function publishAgentLog(entry) {
11
+ pushBounded(agentLogBuf, entry);
12
+ for (const l of listeners)
13
+ l('agent', entry);
14
+ }
15
+ export function publishHostLog(entry) {
16
+ pushBounded(hostLogBuf, entry);
17
+ for (const l of listeners)
18
+ l('host', entry);
19
+ }
20
+ export function getLogSnapshot(source) {
21
+ return source === 'agent' ? [...agentLogBuf] : [...hostLogBuf];
22
+ }
23
+ export function subscribeLogs(listener) {
24
+ listeners.add(listener);
25
+ return () => listeners.delete(listener);
26
+ }
@@ -0,0 +1,209 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
2
+ import { FilesManager } from '../files_manager.js';
3
+ import fs from 'fs/promises';
4
+ import path from 'path';
5
+ describe('FilesManager', () => {
6
+ let filesManager;
7
+ let testDir;
8
+ let config;
9
+ beforeEach(async () => {
10
+ // 创建临时测试目录
11
+ testDir = path.join(process.cwd(), 'test-files-' + Date.now());
12
+ config = {
13
+ rootDir: testDir,
14
+ maxFileSize: 1024 * 1024, // 1MB
15
+ allowedPaths: [testDir]
16
+ };
17
+ filesManager = new FilesManager(config);
18
+ // 确保测试目录存在
19
+ await fs.mkdir(testDir, { recursive: true });
20
+ });
21
+ afterEach(async () => {
22
+ // 清理测试目录
23
+ try {
24
+ await fs.rm(testDir, { recursive: true, force: true });
25
+ }
26
+ catch (error) {
27
+ // 忽略清理错误
28
+ }
29
+ });
30
+ describe('putFile', () => {
31
+ it('should upload a file successfully', async () => {
32
+ // 创建测试文件
33
+ const testFile = path.join(testDir, 'source.txt');
34
+ await fs.writeFile(testFile, 'Hello, World!');
35
+ // 上传文件
36
+ const result = await filesManager.putFile(testFile, 'uploaded.txt');
37
+ expect(result.name).toBe('uploaded.txt');
38
+ expect(result.path).toBe('uploaded.txt');
39
+ expect(result.size).toBe(13); // "Hello, World!" 的长度
40
+ expect(result.isFile).toBe(true);
41
+ expect(result.isDirectory).toBe(false);
42
+ // 验证文件确实存在
43
+ const uploadedFile = path.join(testDir, 'uploaded.txt');
44
+ const content = await fs.readFile(uploadedFile, 'utf-8');
45
+ expect(content).toBe('Hello, World!');
46
+ });
47
+ it('should use original filename when destination not specified', async () => {
48
+ const testFile = path.join(testDir, 'original.txt');
49
+ await fs.writeFile(testFile, 'Test content');
50
+ const result = await filesManager.putFile(testFile);
51
+ expect(result.name).toBe('original.txt');
52
+ expect(result.path).toBe('original.txt');
53
+ });
54
+ it('should reject files exceeding max size', async () => {
55
+ // 创建一个超过限制的大文件
56
+ const testFile = path.join(testDir, 'large.txt');
57
+ const largeContent = 'x'.repeat(config.maxFileSize + 1);
58
+ await fs.writeFile(testFile, largeContent);
59
+ await expect(filesManager.putFile(testFile)).rejects.toThrow('exceeds maximum allowed size');
60
+ });
61
+ it('should create subdirectories when needed', async () => {
62
+ const testFile = path.join(testDir, 'source.txt');
63
+ await fs.writeFile(testFile, 'Test content');
64
+ const result = await filesManager.putFile(testFile, 'subdir/nested.txt');
65
+ expect(result.path).toBe('subdir/nested.txt');
66
+ // 验证子目录和文件都存在
67
+ const nestedFile = path.join(testDir, 'subdir/nested.txt');
68
+ const exists = await fs.access(nestedFile).then(() => true).catch(() => false);
69
+ expect(exists).toBe(true);
70
+ });
71
+ });
72
+ describe('getFile', () => {
73
+ it('should retrieve file content successfully', async () => {
74
+ // 创建测试文件
75
+ const testFile = path.join(testDir, 'test.txt');
76
+ const testContent = 'Test file content';
77
+ await fs.writeFile(testFile, testContent);
78
+ const result = await filesManager.getFile('test.txt');
79
+ expect(result.toString()).toBe(testContent);
80
+ });
81
+ it('should throw error for non-existent file', async () => {
82
+ await expect(filesManager.getFile('nonexistent.txt')).rejects.toThrow('File not found');
83
+ });
84
+ it('should throw error for directory path', async () => {
85
+ const testDir = path.join(config.rootDir, 'testdir');
86
+ await fs.mkdir(testDir);
87
+ await expect(filesManager.getFile('testdir')).rejects.toThrow('is not a file');
88
+ });
89
+ });
90
+ describe('listDir', () => {
91
+ beforeEach(async () => {
92
+ // 创建测试文件和目录结构
93
+ await fs.writeFile(path.join(testDir, 'file1.txt'), 'content1');
94
+ await fs.writeFile(path.join(testDir, 'file2.txt'), 'content2');
95
+ await fs.mkdir(path.join(testDir, 'subdir'));
96
+ await fs.writeFile(path.join(testDir, 'subdir', 'nested.txt'), 'nested content');
97
+ await fs.writeFile(path.join(testDir, '.hidden'), 'hidden content');
98
+ });
99
+ it('should list directory contents', async () => {
100
+ const result = await filesManager.listDir();
101
+ expect(result).toHaveLength(3); // file1.txt, file2.txt, subdir
102
+ expect(result.map(e => e.name)).toContain('file1.txt');
103
+ expect(result.map(e => e.name)).toContain('file2.txt');
104
+ expect(result.map(e => e.name)).toContain('subdir');
105
+ });
106
+ it('should include hidden files when all option is true', async () => {
107
+ const result = await filesManager.listDir(undefined, { all: true });
108
+ expect(result).toHaveLength(4); // 包括 .hidden
109
+ expect(result.map(e => e.name)).toContain('.hidden');
110
+ });
111
+ it('should list recursively when recursive option is true', async () => {
112
+ const result = await filesManager.listDir(undefined, { recursive: true });
113
+ expect(result.length).toBeGreaterThan(3);
114
+ expect(result.map(e => e.name)).toContain('nested.txt');
115
+ });
116
+ it('should filter by pattern', async () => {
117
+ const result = await filesManager.listDir(undefined, { pattern: 'file*.txt' });
118
+ expect(result).toHaveLength(2);
119
+ expect(result.map(e => e.name)).toContain('file1.txt');
120
+ expect(result.map(e => e.name)).toContain('file2.txt');
121
+ });
122
+ });
123
+ describe('remove', () => {
124
+ beforeEach(async () => {
125
+ // 创建测试文件和目录
126
+ await fs.writeFile(path.join(testDir, 'to-delete.txt'), 'content');
127
+ await fs.mkdir(path.join(testDir, 'empty-dir'));
128
+ await fs.mkdir(path.join(testDir, 'non-empty'));
129
+ await fs.writeFile(path.join(testDir, 'non-empty', 'file.txt'), 'content');
130
+ });
131
+ it('should delete a file successfully', async () => {
132
+ await filesManager.remove('to-delete.txt');
133
+ const exists = await fs.access(path.join(testDir, 'to-delete.txt'))
134
+ .then(() => true).catch(() => false);
135
+ expect(exists).toBe(false);
136
+ });
137
+ it('should delete empty directory successfully', async () => {
138
+ await filesManager.remove('empty-dir');
139
+ const exists = await fs.access(path.join(testDir, 'empty-dir'))
140
+ .then(() => true).catch(() => false);
141
+ expect(exists).toBe(false);
142
+ });
143
+ it('should delete non-empty directory when recursive is true', async () => {
144
+ await filesManager.remove('non-empty', true);
145
+ const exists = await fs.access(path.join(testDir, 'non-empty'))
146
+ .then(() => true).catch(() => false);
147
+ expect(exists).toBe(false);
148
+ });
149
+ it('should throw error for non-empty directory when recursive is false', async () => {
150
+ await expect(filesManager.remove('non-empty', false))
151
+ .rejects.toThrow('Directory is not empty');
152
+ });
153
+ it('should handle non-existent paths gracefully', async () => {
154
+ await expect(filesManager.remove('non-existent')).resolves.not.toThrow();
155
+ });
156
+ });
157
+ describe('makeDir', () => {
158
+ it('should create directory successfully', async () => {
159
+ await filesManager.makeDir('new-dir');
160
+ const dirPath = path.join(testDir, 'new-dir');
161
+ const stat = await fs.stat(dirPath);
162
+ expect(stat.isDirectory()).toBe(true);
163
+ });
164
+ it('should create nested directories when parents is true', async () => {
165
+ await filesManager.makeDir('nested/deep/dir', true);
166
+ const dirPath = path.join(testDir, 'nested/deep/dir');
167
+ const stat = await fs.stat(dirPath);
168
+ expect(stat.isDirectory()).toBe(true);
169
+ });
170
+ it('should handle existing directory gracefully', async () => {
171
+ await fs.mkdir(path.join(testDir, 'existing'));
172
+ await expect(filesManager.makeDir('existing')).resolves.not.toThrow();
173
+ });
174
+ });
175
+ describe('getStat', () => {
176
+ beforeEach(async () => {
177
+ await fs.writeFile(path.join(testDir, 'test.txt'), 'file content');
178
+ await fs.mkdir(path.join(testDir, 'testdir'));
179
+ });
180
+ it('should get file stats successfully', async () => {
181
+ const result = await filesManager.getStat('test.txt');
182
+ expect(result.name).toBe('test.txt');
183
+ expect(result.path).toBe('test.txt');
184
+ expect(result.isFile).toBe(true);
185
+ expect(result.isDirectory).toBe(false);
186
+ expect(result.size).toBe(12); // "file content" 的长度
187
+ });
188
+ it('should get directory stats successfully', async () => {
189
+ const result = await filesManager.getStat('testdir');
190
+ expect(result.name).toBe('testdir');
191
+ expect(result.path).toBe('testdir');
192
+ expect(result.isFile).toBe(false);
193
+ expect(result.isDirectory).toBe(true);
194
+ });
195
+ it('should throw error for non-existent path', async () => {
196
+ await expect(filesManager.getStat('nonexistent')).rejects.toThrow('Path not found');
197
+ });
198
+ });
199
+ describe('path security', () => {
200
+ it('should prevent directory traversal attacks', async () => {
201
+ await expect(filesManager.getFile('../../../etc/passwd'))
202
+ .rejects.toThrow('Access denied');
203
+ });
204
+ it('should prevent access outside allowed paths', async () => {
205
+ await expect(filesManager.getFile('/etc/passwd'))
206
+ .rejects.toThrow('Access denied');
207
+ });
208
+ });
209
+ });