@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
package/dist/logger.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { publishAgentLog } from './core/log_stream.js';
|
|
5
|
+
export var LogLevel;
|
|
6
|
+
(function (LogLevel) {
|
|
7
|
+
LogLevel["DEBUG"] = "debug";
|
|
8
|
+
LogLevel["INFO"] = "info";
|
|
9
|
+
LogLevel["WARN"] = "warn";
|
|
10
|
+
LogLevel["ERROR"] = "error";
|
|
11
|
+
})(LogLevel || (LogLevel = {}));
|
|
12
|
+
export class AgentLogger {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.logStream = null;
|
|
15
|
+
this.serviceName = 'agent';
|
|
16
|
+
this.config = this.loadConfig();
|
|
17
|
+
if (this.config.enabled) {
|
|
18
|
+
this.initializeLogFile();
|
|
19
|
+
}
|
|
20
|
+
this.currentLogFile = '';
|
|
21
|
+
}
|
|
22
|
+
loadConfig() {
|
|
23
|
+
return {
|
|
24
|
+
enabled: process.env.EMBEDLINK_DEBUG === '1' || true, // Agent默认开启日志
|
|
25
|
+
level: process.env.EMBEDLINK_LOG_LEVEL || LogLevel.INFO,
|
|
26
|
+
dir: process.env.EMBEDLINK_LOG_DIR || this.getDefaultLogDir(),
|
|
27
|
+
maxSize: this.parseSize(process.env.EMBEDLINK_LOG_MAX_SIZE) || 10 * 1024 * 1024, // 10MB
|
|
28
|
+
maxFiles: parseInt(process.env.EMBEDLINK_LOG_MAX_FILES || '20'),
|
|
29
|
+
maxAge: parseInt(process.env.EMBEDLINK_LOG_MAX_AGE || '7'), // days
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
getDefaultLogDir() {
|
|
33
|
+
return path.join(os.homedir(), '.config', 'embed_link', 'logs', 'agent');
|
|
34
|
+
}
|
|
35
|
+
parseSize(sizeStr) {
|
|
36
|
+
if (!sizeStr)
|
|
37
|
+
return 0;
|
|
38
|
+
const units = {
|
|
39
|
+
B: 1,
|
|
40
|
+
KB: 1024,
|
|
41
|
+
MB: 1024 * 1024,
|
|
42
|
+
GB: 1024 * 1024 * 1024,
|
|
43
|
+
};
|
|
44
|
+
const match = sizeStr.match(/^(\d+)(B|KB|MB|GB)?$/i);
|
|
45
|
+
if (!match)
|
|
46
|
+
return 0;
|
|
47
|
+
const value = parseInt(match[1]);
|
|
48
|
+
const unit = match[2]?.toUpperCase() || 'B';
|
|
49
|
+
return value * (units[unit] || 1);
|
|
50
|
+
}
|
|
51
|
+
async initializeLogFile() {
|
|
52
|
+
try {
|
|
53
|
+
// 确保日志目录存在
|
|
54
|
+
await fs.promises.mkdir(this.config.dir, { recursive: true });
|
|
55
|
+
// 生成基于启动时间的日志文件名(使用本地时间)
|
|
56
|
+
const now = new Date();
|
|
57
|
+
const year = now.getFullYear();
|
|
58
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
59
|
+
const day = String(now.getDate()).padStart(2, '0');
|
|
60
|
+
const hours = String(now.getHours()).padStart(2, '0');
|
|
61
|
+
const minutes = String(now.getMinutes()).padStart(2, '0');
|
|
62
|
+
const seconds = String(now.getSeconds()).padStart(2, '0');
|
|
63
|
+
const timestamp = `${year}-${month}-${day}T${hours}-${minutes}-${seconds}`;
|
|
64
|
+
this.currentLogFile = path.join(this.config.dir, `${this.serviceName}-${timestamp}.log`);
|
|
65
|
+
// 创建写入流
|
|
66
|
+
this.logStream = fs.createWriteStream(this.currentLogFile, { flags: 'a' });
|
|
67
|
+
// 写入启动标记
|
|
68
|
+
this.write(LogLevel.INFO, 'logger.initialized', {
|
|
69
|
+
pid: process.pid,
|
|
70
|
+
nodeVersion: process.version,
|
|
71
|
+
platform: os.platform(),
|
|
72
|
+
logFile: this.currentLogFile,
|
|
73
|
+
});
|
|
74
|
+
// 设置错误处理
|
|
75
|
+
this.logStream.on('error', (error) => {
|
|
76
|
+
console.error('[embedlink-agent] Logger write error:', error);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
console.error('[embedlink-agent] Failed to initialize logger:', error);
|
|
81
|
+
this.logStream = null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
write(level, event, data) {
|
|
85
|
+
if (!this.config.enabled) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// 检查日志级别
|
|
89
|
+
if (!this.shouldLog(level)) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const logEntry = {
|
|
93
|
+
ts: Date.now(),
|
|
94
|
+
level,
|
|
95
|
+
event,
|
|
96
|
+
data,
|
|
97
|
+
pid: process.pid,
|
|
98
|
+
};
|
|
99
|
+
// UI 实时日志:best-effort 内存缓冲 + WS 广播(由 web.ts 订阅)
|
|
100
|
+
publishAgentLog(logEntry);
|
|
101
|
+
if (!this.logStream) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const logLine = JSON.stringify(logEntry) + '\n';
|
|
105
|
+
try {
|
|
106
|
+
this.logStream.write(logLine);
|
|
107
|
+
// 检查是否需要轮转
|
|
108
|
+
this.checkRotation().catch((error) => {
|
|
109
|
+
console.error('[embedlink-agent] Log rotation error:', error);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
console.error('[embedlink-agent] Logger write error:', error);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
shouldLog(level) {
|
|
117
|
+
const levels = [LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR];
|
|
118
|
+
const currentLevelIndex = levels.indexOf(this.config.level);
|
|
119
|
+
const messageLevelIndex = levels.indexOf(level);
|
|
120
|
+
return messageLevelIndex >= currentLevelIndex;
|
|
121
|
+
}
|
|
122
|
+
async checkRotation() {
|
|
123
|
+
if (!this.currentLogFile || !this.logStream) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const stats = await fs.promises.stat(this.currentLogFile);
|
|
128
|
+
if (stats.size > this.config.maxSize) {
|
|
129
|
+
await this.rotate();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
// 忽略文件不存在的错误
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async rotate() {
|
|
137
|
+
if (!this.logStream)
|
|
138
|
+
return;
|
|
139
|
+
try {
|
|
140
|
+
// 关闭当前文件流
|
|
141
|
+
this.logStream.end();
|
|
142
|
+
// 创建新的日志文件
|
|
143
|
+
await this.initializeLogFile();
|
|
144
|
+
// 清理旧文件
|
|
145
|
+
await this.cleanupOldFiles();
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
console.error('[embedlink-agent] Log rotation error:', error);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async cleanupOldFiles() {
|
|
152
|
+
try {
|
|
153
|
+
const files = await fs.promises.readdir(this.config.dir);
|
|
154
|
+
const logFiles = files
|
|
155
|
+
.filter((f) => f.startsWith(`${this.serviceName}-`) && f.endsWith('.log'))
|
|
156
|
+
.map((f) => ({ name: f, path: path.join(this.config.dir, f) }));
|
|
157
|
+
// 按修改时间排序
|
|
158
|
+
const fileStats = await Promise.all(logFiles.map(async (f) => ({
|
|
159
|
+
...f,
|
|
160
|
+
mtime: (await fs.promises.stat(f.path)).mtime,
|
|
161
|
+
})));
|
|
162
|
+
fileStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
163
|
+
// 删除超出数量��制的文件
|
|
164
|
+
const filesToDelete = fileStats.slice(this.config.maxFiles);
|
|
165
|
+
await Promise.all(filesToDelete.map((f) => fs.promises.unlink(f.path).catch(() => {
|
|
166
|
+
// 忽略删除错误
|
|
167
|
+
})));
|
|
168
|
+
// 删除超出时间限制的文件
|
|
169
|
+
const cutoffDate = new Date(Date.now() - this.config.maxAge * 24 * 60 * 60 * 1000);
|
|
170
|
+
const oldFiles = fileStats.filter((f) => f.mtime < cutoffDate);
|
|
171
|
+
await Promise.all(oldFiles.map((f) => fs.promises.unlink(f.path).catch(() => {
|
|
172
|
+
// 忽略删除错误
|
|
173
|
+
})));
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
console.error('[embedlink-agent] Log cleanup error:', error);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
close() {
|
|
180
|
+
if (this.logStream) {
|
|
181
|
+
this.write(LogLevel.INFO, 'logger.closed', { reason: 'shutdown' });
|
|
182
|
+
this.logStream.end();
|
|
183
|
+
this.logStream = null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async getLogFiles() {
|
|
187
|
+
try {
|
|
188
|
+
const files = await fs.promises.readdir(this.config.dir);
|
|
189
|
+
return files
|
|
190
|
+
.filter((f) => f.startsWith(`${this.serviceName}-`) && f.endsWith('.log'))
|
|
191
|
+
.sort()
|
|
192
|
+
.reverse(); // Newest first
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async readLogFile(filename, lines) {
|
|
199
|
+
try {
|
|
200
|
+
const p = path.join(this.config.dir, filename);
|
|
201
|
+
// Ensure path traversal protection
|
|
202
|
+
if (!p.startsWith(this.config.dir))
|
|
203
|
+
throw new Error('Invalid path');
|
|
204
|
+
const content = await fs.promises.readFile(p, 'utf8');
|
|
205
|
+
const allLines = content.trim().split('\n');
|
|
206
|
+
const targetLines = lines ? allLines.slice(-lines) : allLines;
|
|
207
|
+
return targetLines.map((line) => {
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(line);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return { message: line, ts: Date.now(), level: 'unknown' };
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
console.error('Failed to read log file:', e);
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// 单例实例
|
|
223
|
+
let agentLogger = null;
|
|
224
|
+
export function getAgentLogger() {
|
|
225
|
+
if (!agentLogger) {
|
|
226
|
+
agentLogger = new AgentLogger();
|
|
227
|
+
}
|
|
228
|
+
return agentLogger;
|
|
229
|
+
}
|
|
230
|
+
// 便捷函数
|
|
231
|
+
export function logAgent(level, event, data) {
|
|
232
|
+
getAgentLogger().write(level, event, data);
|
|
233
|
+
}
|
|
234
|
+
export function logAgentDebug(event, data) {
|
|
235
|
+
logAgent(LogLevel.DEBUG, event, data);
|
|
236
|
+
}
|
|
237
|
+
export function logAgentInfo(event, data) {
|
|
238
|
+
logAgent(LogLevel.INFO, event, data);
|
|
239
|
+
}
|
|
240
|
+
export function logAgentWarn(event, data) {
|
|
241
|
+
logAgent(LogLevel.WARN, event, data);
|
|
242
|
+
}
|
|
243
|
+
export function logAgentError(event, data) {
|
|
244
|
+
logAgent(LogLevel.ERROR, event, data);
|
|
245
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
function sanitizeMacroStep(raw) {
|
|
6
|
+
if (!raw || typeof raw !== 'object') {
|
|
7
|
+
throw new Error('invalid macro step: expected object');
|
|
8
|
+
}
|
|
9
|
+
const kind = String(raw.kind || '').trim();
|
|
10
|
+
if (kind === 'send') {
|
|
11
|
+
const dataBase64 = String(raw.dataBase64 || '');
|
|
12
|
+
if (!dataBase64)
|
|
13
|
+
throw new Error('invalid send step: missing dataBase64');
|
|
14
|
+
const text = String(raw.text || '');
|
|
15
|
+
return { kind: 'send', dataBase64, text };
|
|
16
|
+
}
|
|
17
|
+
if (kind === 'delay') {
|
|
18
|
+
const delayMs = Number(raw.delayMs);
|
|
19
|
+
if (!Number.isFinite(delayMs) || delayMs < 0) {
|
|
20
|
+
throw new Error('invalid delay step: delayMs must be a non-negative number');
|
|
21
|
+
}
|
|
22
|
+
return { kind: 'delay', delayMs: Math.floor(delayMs) };
|
|
23
|
+
}
|
|
24
|
+
if (kind === 'expect') {
|
|
25
|
+
const waitFor = String(raw.waitFor || '');
|
|
26
|
+
if (!waitFor)
|
|
27
|
+
throw new Error('invalid expect step: missing waitFor');
|
|
28
|
+
const timeoutMsRaw = raw.timeoutMs;
|
|
29
|
+
const timeoutMs = typeof timeoutMsRaw === 'number' && Number.isFinite(timeoutMsRaw) && timeoutMsRaw > 0
|
|
30
|
+
? Math.floor(timeoutMsRaw)
|
|
31
|
+
: undefined;
|
|
32
|
+
return { kind: 'expect', waitFor, timeoutMs };
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`invalid macro step: unknown kind: ${kind}`);
|
|
35
|
+
}
|
|
36
|
+
function sanitizeMacroSteps(raw) {
|
|
37
|
+
if (!Array.isArray(raw))
|
|
38
|
+
throw new Error('invalid macro steps: steps must be an array');
|
|
39
|
+
return raw.map((s, i) => {
|
|
40
|
+
try {
|
|
41
|
+
return sanitizeMacroStep(s);
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
throw new Error(`invalid macro step at index ${i}: ${e?.message || String(e)}`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'embed_link');
|
|
49
|
+
const MACROS_PATH = path.join(CONFIG_DIR, 'macros.json');
|
|
50
|
+
export function getMacrosPath() {
|
|
51
|
+
return MACROS_PATH;
|
|
52
|
+
}
|
|
53
|
+
async function readJsonOrNull(filePath) {
|
|
54
|
+
try {
|
|
55
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(raw);
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
throw new Error(`invalid macros store JSON: ${filePath}: ${e?.message || String(e)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
if (e?.code === 'ENOENT')
|
|
65
|
+
return null;
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function writeJsonAtomic(filePath, obj) {
|
|
70
|
+
const dir = path.dirname(filePath);
|
|
71
|
+
await fs.mkdir(dir, { recursive: true });
|
|
72
|
+
const base = path.basename(filePath);
|
|
73
|
+
const nonce = `${Date.now()}.${randomUUID()}`;
|
|
74
|
+
const tmp = path.join(dir, `${base}.${nonce}.tmp`);
|
|
75
|
+
const bak = path.join(dir, `${base}.${nonce}.bak`);
|
|
76
|
+
const content = JSON.stringify(obj, null, 2);
|
|
77
|
+
await fs.writeFile(tmp, content, 'utf8');
|
|
78
|
+
// Cross-platform atomic-ish replace:
|
|
79
|
+
// - POSIX: rename overwrites
|
|
80
|
+
// - Windows: rename fails if dst exists; use a best-effort backup swap.
|
|
81
|
+
try {
|
|
82
|
+
await fs.rename(tmp, filePath);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
// fall through to backup swap
|
|
87
|
+
if (e?.code !== 'EEXIST' && e?.code !== 'EPERM' && e?.code !== 'EACCES') {
|
|
88
|
+
// cleanup tmp best-effort, but don't hide the root error
|
|
89
|
+
try {
|
|
90
|
+
await fs.unlink(tmp);
|
|
91
|
+
}
|
|
92
|
+
catch { }
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
let hadOld = false;
|
|
97
|
+
try {
|
|
98
|
+
await fs.rename(filePath, bak);
|
|
99
|
+
hadOld = true;
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
if (e?.code !== 'ENOENT') {
|
|
103
|
+
try {
|
|
104
|
+
await fs.unlink(tmp);
|
|
105
|
+
}
|
|
106
|
+
catch { }
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
await fs.rename(tmp, filePath);
|
|
112
|
+
if (hadOld) {
|
|
113
|
+
// best-effort cleanup of backup file
|
|
114
|
+
try {
|
|
115
|
+
await fs.unlink(bak);
|
|
116
|
+
}
|
|
117
|
+
catch { }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
// restore old if we moved it
|
|
122
|
+
if (hadOld) {
|
|
123
|
+
try {
|
|
124
|
+
await fs.rename(bak, filePath);
|
|
125
|
+
}
|
|
126
|
+
catch { }
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
await fs.unlink(tmp);
|
|
130
|
+
}
|
|
131
|
+
catch { }
|
|
132
|
+
throw e;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function sanitizeStoredScript(raw) {
|
|
136
|
+
if (!raw || typeof raw !== 'object') {
|
|
137
|
+
throw new Error('invalid macro script: expected object');
|
|
138
|
+
}
|
|
139
|
+
const id = String(raw.id || '').trim();
|
|
140
|
+
const name = String(raw.name || '').trim();
|
|
141
|
+
const order = Number(raw.order);
|
|
142
|
+
const createdAtMs = Number(raw.createdAtMs);
|
|
143
|
+
const updatedAtMs = Number(raw.updatedAtMs);
|
|
144
|
+
if (!id)
|
|
145
|
+
throw new Error('invalid macro script: missing id');
|
|
146
|
+
if (!name)
|
|
147
|
+
throw new Error('invalid macro script: missing name');
|
|
148
|
+
if (!Number.isFinite(order))
|
|
149
|
+
throw new Error('invalid macro script: order must be a number');
|
|
150
|
+
if (!Number.isFinite(createdAtMs))
|
|
151
|
+
throw new Error('invalid macro script: createdAtMs must be a number');
|
|
152
|
+
if (!Number.isFinite(updatedAtMs))
|
|
153
|
+
throw new Error('invalid macro script: updatedAtMs must be a number');
|
|
154
|
+
const steps = sanitizeMacroSteps(raw.steps);
|
|
155
|
+
return {
|
|
156
|
+
id,
|
|
157
|
+
name,
|
|
158
|
+
order: Math.floor(order),
|
|
159
|
+
steps,
|
|
160
|
+
version: 1,
|
|
161
|
+
createdAtMs: Math.floor(createdAtMs),
|
|
162
|
+
updatedAtMs: Math.floor(updatedAtMs),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function normalizeStoreFile(rec) {
|
|
166
|
+
if (rec === null) {
|
|
167
|
+
return { version: 1, scripts: [] };
|
|
168
|
+
}
|
|
169
|
+
if (!rec || typeof rec !== 'object') {
|
|
170
|
+
throw new Error('invalid macros store: expected object');
|
|
171
|
+
}
|
|
172
|
+
if (rec.version !== 1) {
|
|
173
|
+
throw new Error(`unsupported macros store version: ${String(rec.version)}`);
|
|
174
|
+
}
|
|
175
|
+
if (!Array.isArray(rec.scripts)) {
|
|
176
|
+
throw new Error('invalid macros store: scripts must be an array');
|
|
177
|
+
}
|
|
178
|
+
const scripts = rec.scripts.map((s, i) => {
|
|
179
|
+
try {
|
|
180
|
+
return sanitizeStoredScript(s);
|
|
181
|
+
}
|
|
182
|
+
catch (e) {
|
|
183
|
+
throw new Error(`invalid macro script at index ${i}: ${e?.message || String(e)}`);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
return { version: 1, scripts };
|
|
187
|
+
}
|
|
188
|
+
function sortScripts(scripts) {
|
|
189
|
+
return [...scripts].sort((a, b) => {
|
|
190
|
+
const ao = Number.isFinite(a.order) ? a.order : 0;
|
|
191
|
+
const bo = Number.isFinite(b.order) ? b.order : 0;
|
|
192
|
+
if (ao !== bo)
|
|
193
|
+
return ao - bo;
|
|
194
|
+
return String(a.name || '').localeCompare(String(b.name || ''));
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
export async function listMacroScripts() {
|
|
198
|
+
const rec = normalizeStoreFile(await readJsonOrNull(MACROS_PATH));
|
|
199
|
+
return sortScripts(rec.scripts);
|
|
200
|
+
}
|
|
201
|
+
export async function getMacroScript(id) {
|
|
202
|
+
const scripts = await listMacroScripts();
|
|
203
|
+
return scripts.find((s) => s.id === id) || null;
|
|
204
|
+
}
|
|
205
|
+
export async function upsertMacroScript(input) {
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
const rec = normalizeStoreFile(await readJsonOrNull(MACROS_PATH));
|
|
208
|
+
const id = (input.id || '').trim() || randomUUID();
|
|
209
|
+
const idx = rec.scripts.findIndex((s) => s.id === id);
|
|
210
|
+
const name = String(input.name || '').trim();
|
|
211
|
+
if (!name)
|
|
212
|
+
throw new Error('invalid macro script: missing name');
|
|
213
|
+
const order = Number(input.order);
|
|
214
|
+
if (!Number.isFinite(order))
|
|
215
|
+
throw new Error('invalid macro script: order must be a number');
|
|
216
|
+
const steps = sanitizeMacroSteps(input.steps);
|
|
217
|
+
const next = {
|
|
218
|
+
id,
|
|
219
|
+
name,
|
|
220
|
+
order: Math.floor(order),
|
|
221
|
+
steps,
|
|
222
|
+
version: 1,
|
|
223
|
+
createdAtMs: idx >= 0 ? rec.scripts[idx].createdAtMs : now,
|
|
224
|
+
updatedAtMs: now,
|
|
225
|
+
};
|
|
226
|
+
if (idx >= 0)
|
|
227
|
+
rec.scripts[idx] = next;
|
|
228
|
+
else
|
|
229
|
+
rec.scripts.push(next);
|
|
230
|
+
await writeJsonAtomic(MACROS_PATH, { version: 1, scripts: rec.scripts });
|
|
231
|
+
return next;
|
|
232
|
+
}
|
|
233
|
+
export async function deleteMacroScript(id) {
|
|
234
|
+
const rec = normalizeStoreFile(await readJsonOrNull(MACROS_PATH));
|
|
235
|
+
const before = rec.scripts.length;
|
|
236
|
+
rec.scripts = rec.scripts.filter((s) => s.id !== id);
|
|
237
|
+
if (rec.scripts.length === before)
|
|
238
|
+
return false;
|
|
239
|
+
await writeJsonAtomic(MACROS_PATH, { version: 1, scripts: rec.scripts });
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { getBoardUartResourceManager } from '../board_uart/resource.js';
|
|
3
|
+
import { addSessionConsumer, listBoardUartSessions, removeSessionConsumer } from '../board_uart/sessions.js';
|
|
4
|
+
let current = null;
|
|
5
|
+
export function getCurrentRun() {
|
|
6
|
+
if (!current)
|
|
7
|
+
return null;
|
|
8
|
+
return {
|
|
9
|
+
runId: current.runId,
|
|
10
|
+
scriptId: current.script.id,
|
|
11
|
+
status: current.status,
|
|
12
|
+
stepIndex: current.stepIndex,
|
|
13
|
+
stepCount: current.script.steps.length,
|
|
14
|
+
step: current.script.steps[current.stepIndex],
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function stopCurrentRun() {
|
|
18
|
+
if (!current || current.status !== 'running')
|
|
19
|
+
return false;
|
|
20
|
+
current.cancelled = true;
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
async function sleep(ms) {
|
|
24
|
+
const delay = Number.isFinite(ms) && ms > 0 ? ms : 0;
|
|
25
|
+
if (!delay)
|
|
26
|
+
return;
|
|
27
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
28
|
+
}
|
|
29
|
+
async function resolveSessionId(preferred) {
|
|
30
|
+
const id = (preferred || '').trim();
|
|
31
|
+
if (id)
|
|
32
|
+
return id;
|
|
33
|
+
const sessions = await listBoardUartSessions();
|
|
34
|
+
const def = sessions.find((s) => s.isDefault);
|
|
35
|
+
if (def)
|
|
36
|
+
return def.id;
|
|
37
|
+
if (sessions.length > 0)
|
|
38
|
+
return sessions[0].id;
|
|
39
|
+
throw new Error('No active board uart session');
|
|
40
|
+
}
|
|
41
|
+
async function waitForOutput(params) {
|
|
42
|
+
const mgr = getBoardUartResourceManager();
|
|
43
|
+
// respect BoardUart suspension
|
|
44
|
+
await mgr.getStatus();
|
|
45
|
+
const waitFor = String(params.waitFor || '');
|
|
46
|
+
if (!waitFor)
|
|
47
|
+
return;
|
|
48
|
+
const sessionId = params.sessionId;
|
|
49
|
+
const timeoutMs = params.timeoutMs;
|
|
50
|
+
const consumerId = `macro-expect-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
51
|
+
let done = false;
|
|
52
|
+
let output = '';
|
|
53
|
+
const maxChars = 16384;
|
|
54
|
+
const cleanup = () => {
|
|
55
|
+
if (done)
|
|
56
|
+
return;
|
|
57
|
+
done = true;
|
|
58
|
+
try {
|
|
59
|
+
removeSessionConsumer(sessionId, consumerId);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// ignore
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
return await new Promise((resolve, reject) => {
|
|
66
|
+
const timer = setTimeout(() => {
|
|
67
|
+
cleanup();
|
|
68
|
+
reject(new Error('expect timeout'));
|
|
69
|
+
}, timeoutMs);
|
|
70
|
+
addSessionConsumer({
|
|
71
|
+
consumerId,
|
|
72
|
+
sessionId,
|
|
73
|
+
onData: (chunk) => {
|
|
74
|
+
if (done)
|
|
75
|
+
return;
|
|
76
|
+
try {
|
|
77
|
+
const text = chunk.toString('utf8');
|
|
78
|
+
output = (output + text).slice(-maxChars);
|
|
79
|
+
if (output.includes(waitFor)) {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
cleanup();
|
|
82
|
+
resolve();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// ignore decode errors
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
onError: (e) => {
|
|
90
|
+
if (done)
|
|
91
|
+
return;
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
cleanup();
|
|
94
|
+
reject(e);
|
|
95
|
+
},
|
|
96
|
+
}).catch((e) => {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
cleanup();
|
|
99
|
+
reject(e);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export async function runMacroScript(params) {
|
|
104
|
+
if (current && current.status === 'running') {
|
|
105
|
+
return { status: 'busy', message: 'another macro is running' };
|
|
106
|
+
}
|
|
107
|
+
const run = {
|
|
108
|
+
runId: params.runId,
|
|
109
|
+
script: params.script,
|
|
110
|
+
status: 'running',
|
|
111
|
+
stepIndex: 0,
|
|
112
|
+
cancelled: false,
|
|
113
|
+
};
|
|
114
|
+
current = run;
|
|
115
|
+
const push = (patch) => {
|
|
116
|
+
params.onUpdate({
|
|
117
|
+
runId: run.runId,
|
|
118
|
+
scriptId: run.script.id,
|
|
119
|
+
status: run.status,
|
|
120
|
+
stepIndex: run.stepIndex,
|
|
121
|
+
stepCount: run.script.steps.length,
|
|
122
|
+
step: run.script.steps[run.stepIndex],
|
|
123
|
+
...patch,
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
try {
|
|
127
|
+
const sessionId = await resolveSessionId(params.sessionId);
|
|
128
|
+
const mgr = getBoardUartResourceManager();
|
|
129
|
+
push({ status: 'running', message: 'started' });
|
|
130
|
+
for (let i = 0; i < run.script.steps.length; i++) {
|
|
131
|
+
run.stepIndex = i;
|
|
132
|
+
const step = run.script.steps[i];
|
|
133
|
+
if (run.cancelled) {
|
|
134
|
+
run.status = 'stopped';
|
|
135
|
+
push({ status: 'stopped', message: 'stopped' });
|
|
136
|
+
return { status: 'stopped' };
|
|
137
|
+
}
|
|
138
|
+
push({ status: 'running', stepIndex: i, step });
|
|
139
|
+
if (step.kind === 'send') {
|
|
140
|
+
const buf = Buffer.from(String(step.dataBase64 || ''), 'base64');
|
|
141
|
+
await mgr.write({ sessionId, data: buf });
|
|
142
|
+
}
|
|
143
|
+
else if (step.kind === 'delay') {
|
|
144
|
+
await sleep(step.delayMs);
|
|
145
|
+
}
|
|
146
|
+
else if (step.kind === 'expect') {
|
|
147
|
+
const timeoutMs = typeof step.timeoutMs === 'number' && step.timeoutMs > 0
|
|
148
|
+
? step.timeoutMs
|
|
149
|
+
: params.defaultExpectTimeoutMs;
|
|
150
|
+
await waitForOutput({ sessionId, waitFor: step.waitFor, timeoutMs });
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
throw new Error(`unknown step kind: ${step.kind}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
run.status = 'done';
|
|
157
|
+
push({ status: 'done', message: 'done', stepIndex: run.script.steps.length });
|
|
158
|
+
return { status: 'done' };
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
run.status = 'error';
|
|
162
|
+
push({ status: 'error', message: e?.message || String(e) });
|
|
163
|
+
return { status: 'error', message: e?.message || String(e) };
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
current = null;
|
|
167
|
+
}
|
|
168
|
+
}
|