@yeaft/webchat-agent 0.0.234 → 0.0.236
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/connection/buffer.js +87 -0
- package/connection/heartbeat.js +47 -0
- package/connection/index.js +89 -0
- package/connection/message-router.js +271 -0
- package/connection/upgrade-worker-template.js +103 -0
- package/connection/upgrade.js +294 -0
- package/crew/control.js +364 -0
- package/crew/human-interaction.js +115 -0
- package/crew/persistence.js +287 -0
- package/crew/role-management.js +131 -0
- package/crew/role-output.js +315 -0
- package/crew/role-query.js +309 -0
- package/crew/routing.js +194 -0
- package/crew/session.js +474 -0
- package/crew/shared-dir.js +116 -0
- package/crew/task-files.js +370 -0
- package/crew/ui-messages.js +246 -0
- package/crew/worktree.js +130 -0
- package/package.json +6 -2
- package/service/config.js +133 -0
- package/service/index.js +99 -0
- package/service/linux.js +111 -0
- package/service/macos.js +137 -0
- package/service/windows.js +181 -0
- package/workbench/file-ops.js +436 -0
- package/workbench/file-search.js +66 -0
- package/workbench/git-ops.js +313 -0
- package/workbench/transfer.js +99 -0
- package/workbench/utils.js +41 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crew — UI 消息辅助函数
|
|
3
|
+
* sendCrewMessage, sendCrewOutput, sendStatusUpdate, endRoleStreaming, findActiveRole
|
|
4
|
+
*/
|
|
5
|
+
import ctx from '../context.js';
|
|
6
|
+
import { upsertCrewIndex, saveSessionMeta } from './persistence.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 发送 crew 消息到 server(透传到 Web)
|
|
10
|
+
*/
|
|
11
|
+
export function sendCrewMessage(msg) {
|
|
12
|
+
if (ctx.sendToServer) {
|
|
13
|
+
ctx.sendToServer(msg);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Format role label: "icon displayName" or just "displayName" if no icon */
|
|
18
|
+
function roleLabel(r) {
|
|
19
|
+
return r.icon ? `${r.icon} ${r.displayName}` : r.displayName;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 结束指定角色的最后一条 streaming 消息(反向搜索)
|
|
24
|
+
*/
|
|
25
|
+
export function endRoleStreaming(session, roleName) {
|
|
26
|
+
for (let i = session.uiMessages.length - 1; i >= 0; i--) {
|
|
27
|
+
if (session.uiMessages[i].role === roleName && session.uiMessages[i]._streaming) {
|
|
28
|
+
delete session.uiMessages[i]._streaming;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 找到当前活跃的角色(最近一个 turnActive 的)
|
|
36
|
+
*/
|
|
37
|
+
export function findActiveRole(session) {
|
|
38
|
+
for (const [name, state] of session.roleStates) {
|
|
39
|
+
if (state.turnActive) return name;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 发送角色输出到 Web
|
|
46
|
+
*/
|
|
47
|
+
export function sendCrewOutput(session, roleName, outputType, rawMessage, extra = {}) {
|
|
48
|
+
const role = session.roles.get(roleName);
|
|
49
|
+
const roleIcon = role?.icon || '';
|
|
50
|
+
const displayName = role?.displayName || roleName;
|
|
51
|
+
|
|
52
|
+
// 从 roleState 获取当前 task 信息
|
|
53
|
+
const roleState = session.roleStates.get(roleName);
|
|
54
|
+
const taskId = roleState?.currentTask?.taskId || null;
|
|
55
|
+
const taskTitle = roleState?.currentTask?.taskTitle || null;
|
|
56
|
+
|
|
57
|
+
sendCrewMessage({
|
|
58
|
+
type: 'crew_output',
|
|
59
|
+
sessionId: session.id,
|
|
60
|
+
role: roleName,
|
|
61
|
+
roleIcon,
|
|
62
|
+
roleName: displayName,
|
|
63
|
+
outputType, // 'text' | 'tool_use' | 'tool_result' | 'route' | 'system'
|
|
64
|
+
data: rawMessage,
|
|
65
|
+
taskId,
|
|
66
|
+
taskTitle,
|
|
67
|
+
...extra
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ★ 累积 feature 到持久化列表
|
|
71
|
+
if (taskId && taskTitle && !session.features.has(taskId)) {
|
|
72
|
+
session.features.set(taskId, { taskId, taskTitle, createdAt: Date.now() });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ★ 记录精简 UI 消息用于恢复(跳过 tool_use/tool_result,只记录可见内容)
|
|
76
|
+
if (outputType === 'text') {
|
|
77
|
+
const content = rawMessage?.message?.content;
|
|
78
|
+
let text = '';
|
|
79
|
+
if (typeof content === 'string') {
|
|
80
|
+
text = content;
|
|
81
|
+
} else if (Array.isArray(content)) {
|
|
82
|
+
text = content.filter(b => b.type === 'text').map(b => b.text).join('');
|
|
83
|
+
}
|
|
84
|
+
if (!text) return;
|
|
85
|
+
// ★ 反向搜索该角色最后一条 _streaming 消息
|
|
86
|
+
let found = false;
|
|
87
|
+
for (let i = session.uiMessages.length - 1; i >= 0; i--) {
|
|
88
|
+
const msg = session.uiMessages[i];
|
|
89
|
+
if (msg.role === roleName && msg.type === 'text' && msg._streaming) {
|
|
90
|
+
msg.content += text;
|
|
91
|
+
found = true;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (!found) {
|
|
96
|
+
session.uiMessages.push({
|
|
97
|
+
role: roleName, roleIcon, roleName: displayName,
|
|
98
|
+
type: 'text', content: text, _streaming: true,
|
|
99
|
+
taskId, taskTitle,
|
|
100
|
+
timestamp: Date.now()
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
} else if (outputType === 'route') {
|
|
104
|
+
// 结束该角色前一条 streaming
|
|
105
|
+
endRoleStreaming(session, roleName);
|
|
106
|
+
session.uiMessages.push({
|
|
107
|
+
role: roleName, roleIcon, roleName: displayName,
|
|
108
|
+
type: 'route', routeTo: extra.routeTo,
|
|
109
|
+
routeSummary: extra.routeSummary || '',
|
|
110
|
+
content: `→ @${extra.routeTo} ${extra.routeSummary || ''}`,
|
|
111
|
+
taskId, taskTitle,
|
|
112
|
+
timestamp: Date.now()
|
|
113
|
+
});
|
|
114
|
+
} else if (outputType === 'system') {
|
|
115
|
+
const content = rawMessage?.message?.content;
|
|
116
|
+
let text = '';
|
|
117
|
+
if (typeof content === 'string') {
|
|
118
|
+
text = content;
|
|
119
|
+
} else if (Array.isArray(content)) {
|
|
120
|
+
text = content.filter(b => b.type === 'text').map(b => b.text).join('');
|
|
121
|
+
}
|
|
122
|
+
if (!text) return;
|
|
123
|
+
session.uiMessages.push({
|
|
124
|
+
role: roleName, roleIcon, roleName: displayName,
|
|
125
|
+
type: 'system', content: text,
|
|
126
|
+
timestamp: Date.now()
|
|
127
|
+
});
|
|
128
|
+
} else if (outputType === 'tool_use') {
|
|
129
|
+
// 结束该角色前一条 streaming
|
|
130
|
+
endRoleStreaming(session, roleName);
|
|
131
|
+
const content = rawMessage?.message?.content;
|
|
132
|
+
if (Array.isArray(content)) {
|
|
133
|
+
for (const block of content) {
|
|
134
|
+
if (block.type === 'tool_use') {
|
|
135
|
+
// Save trimmed toolInput for restore
|
|
136
|
+
const input = block.input || {};
|
|
137
|
+
let savedInput;
|
|
138
|
+
if (block.name === 'TodoWrite') {
|
|
139
|
+
savedInput = input;
|
|
140
|
+
} else {
|
|
141
|
+
const trimmedInput = {};
|
|
142
|
+
if (input.file_path) trimmedInput.file_path = input.file_path;
|
|
143
|
+
if (input.command) trimmedInput.command = input.command.substring(0, 200);
|
|
144
|
+
if (input.pattern) trimmedInput.pattern = input.pattern;
|
|
145
|
+
if (input.old_string) trimmedInput.old_string = input.old_string.substring(0, 100);
|
|
146
|
+
if (input.new_string) trimmedInput.new_string = input.new_string.substring(0, 100);
|
|
147
|
+
if (input.url) trimmedInput.url = input.url;
|
|
148
|
+
if (input.query) trimmedInput.query = input.query;
|
|
149
|
+
savedInput = Object.keys(trimmedInput).length > 0 ? trimmedInput : null;
|
|
150
|
+
}
|
|
151
|
+
session.uiMessages.push({
|
|
152
|
+
role: roleName, roleIcon, roleName: displayName,
|
|
153
|
+
type: 'tool',
|
|
154
|
+
toolName: block.name,
|
|
155
|
+
toolId: block.id,
|
|
156
|
+
toolInput: savedInput,
|
|
157
|
+
content: `${block.name} ${block.input?.file_path || block.input?.command?.substring(0, 60) || ''}`,
|
|
158
|
+
hasResult: false,
|
|
159
|
+
taskId, taskTitle,
|
|
160
|
+
timestamp: Date.now()
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} else if (outputType === 'tool_result') {
|
|
166
|
+
// 标记对应 tool 的 hasResult
|
|
167
|
+
const toolId = rawMessage?.message?.tool_use_id;
|
|
168
|
+
if (toolId) {
|
|
169
|
+
for (let i = session.uiMessages.length - 1; i >= 0; i--) {
|
|
170
|
+
if (session.uiMessages[i].type === 'tool' && session.uiMessages[i].toolId === toolId) {
|
|
171
|
+
session.uiMessages[i].hasResult = true;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Check for image blocks in tool_result content
|
|
177
|
+
const resultContent = rawMessage?.message?.content;
|
|
178
|
+
if (Array.isArray(resultContent)) {
|
|
179
|
+
for (const item of resultContent) {
|
|
180
|
+
if (item.type === 'image' && item.source?.type === 'base64') {
|
|
181
|
+
sendCrewMessage({
|
|
182
|
+
type: 'crew_image',
|
|
183
|
+
sessionId: session.id,
|
|
184
|
+
role: roleName,
|
|
185
|
+
roleIcon,
|
|
186
|
+
roleName: displayName,
|
|
187
|
+
toolId: toolId || '',
|
|
188
|
+
mimeType: item.source.media_type,
|
|
189
|
+
data: item.source.data,
|
|
190
|
+
taskId, taskTitle
|
|
191
|
+
});
|
|
192
|
+
session.uiMessages.push({
|
|
193
|
+
role: roleName, roleIcon, roleName: displayName,
|
|
194
|
+
type: 'image', toolId: toolId || '',
|
|
195
|
+
mimeType: item.source.media_type,
|
|
196
|
+
taskId, taskTitle,
|
|
197
|
+
timestamp: Date.now()
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// tool 只保存精简信息(toolName + 摘要),不存完整 toolInput/toolResult
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 发送 session 状态更新
|
|
208
|
+
*/
|
|
209
|
+
export function sendStatusUpdate(session) {
|
|
210
|
+
const currentRole = findActiveRole(session);
|
|
211
|
+
|
|
212
|
+
sendCrewMessage({
|
|
213
|
+
type: 'crew_status',
|
|
214
|
+
sessionId: session.id,
|
|
215
|
+
status: session.status,
|
|
216
|
+
currentRole,
|
|
217
|
+
round: session.round,
|
|
218
|
+
costUsd: session.costUsd,
|
|
219
|
+
totalInputTokens: session.totalInputTokens,
|
|
220
|
+
totalOutputTokens: session.totalOutputTokens,
|
|
221
|
+
roles: Array.from(session.roles.values()).map(r => ({
|
|
222
|
+
name: r.name,
|
|
223
|
+
displayName: r.displayName,
|
|
224
|
+
icon: r.icon,
|
|
225
|
+
description: r.description,
|
|
226
|
+
isDecisionMaker: r.isDecisionMaker || false,
|
|
227
|
+
model: r.model,
|
|
228
|
+
roleType: r.roleType,
|
|
229
|
+
groupIndex: r.groupIndex
|
|
230
|
+
})),
|
|
231
|
+
activeRoles: Array.from(session.roleStates.entries())
|
|
232
|
+
.filter(([, s]) => s.turnActive)
|
|
233
|
+
.map(([name]) => name),
|
|
234
|
+
currentToolByRole: Object.fromEntries(
|
|
235
|
+
Array.from(session.roleStates.entries())
|
|
236
|
+
.filter(([, s]) => s.turnActive && s.currentTool)
|
|
237
|
+
.map(([name, s]) => [name, s.currentTool])
|
|
238
|
+
),
|
|
239
|
+
features: Array.from(session.features.values()),
|
|
240
|
+
initProgress: session.initProgress || null
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// 异步更新持久化
|
|
244
|
+
upsertCrewIndex(session).catch(e => console.warn('[Crew] Failed to update index:', e.message));
|
|
245
|
+
saveSessionMeta(session).catch(e => console.warn('[Crew] Failed to save session meta:', e.message));
|
|
246
|
+
}
|
package/crew/worktree.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crew — Git Worktree 管理
|
|
3
|
+
* 为开发组创建/清理 git worktrees
|
|
4
|
+
*/
|
|
5
|
+
import { promises as fs } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { execFile as execFileCb } from 'child_process';
|
|
8
|
+
import { promisify } from 'util';
|
|
9
|
+
|
|
10
|
+
const execFile = promisify(execFileCb);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 为开发组创建 git worktree
|
|
14
|
+
* 每个 groupIndex 对应一个 worktree,同组的 dev/rev/test 共享
|
|
15
|
+
*
|
|
16
|
+
* @param {string} projectDir - 主项目目录
|
|
17
|
+
* @param {Array} roles - 展开后的角色列表
|
|
18
|
+
* @returns {Map<number, string>} groupIndex → worktree 路径
|
|
19
|
+
*/
|
|
20
|
+
export async function initWorktrees(projectDir, roles) {
|
|
21
|
+
const groupIndices = [...new Set(roles.filter(r => r.groupIndex > 0).map(r => r.groupIndex))];
|
|
22
|
+
if (groupIndices.length === 0) return new Map();
|
|
23
|
+
|
|
24
|
+
const worktreeBase = join(projectDir, '.worktrees');
|
|
25
|
+
await fs.mkdir(worktreeBase, { recursive: true });
|
|
26
|
+
|
|
27
|
+
// 获取 git 已知的 worktree 列表
|
|
28
|
+
let knownWorktrees = new Set();
|
|
29
|
+
try {
|
|
30
|
+
const { stdout } = await execFile('git', ['worktree', 'list', '--porcelain'], { cwd: projectDir });
|
|
31
|
+
for (const line of stdout.split('\n')) {
|
|
32
|
+
if (line.startsWith('worktree ')) {
|
|
33
|
+
knownWorktrees.add(line.slice('worktree '.length).trim());
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// git worktree list 失败,视为空集
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const worktreeMap = new Map();
|
|
41
|
+
|
|
42
|
+
for (const idx of groupIndices) {
|
|
43
|
+
const wtDir = join(worktreeBase, `dev-${idx}`);
|
|
44
|
+
const branch = `crew/dev-${idx}`;
|
|
45
|
+
|
|
46
|
+
// 检查目录是否存在
|
|
47
|
+
let dirExists = false;
|
|
48
|
+
try {
|
|
49
|
+
await fs.access(wtDir);
|
|
50
|
+
dirExists = true;
|
|
51
|
+
} catch {}
|
|
52
|
+
|
|
53
|
+
if (dirExists) {
|
|
54
|
+
if (knownWorktrees.has(wtDir)) {
|
|
55
|
+
// 目录存在且 git 记录中也有,直接复用
|
|
56
|
+
console.log(`[Crew] Worktree already exists: ${wtDir}`);
|
|
57
|
+
worktreeMap.set(idx, wtDir);
|
|
58
|
+
continue;
|
|
59
|
+
} else {
|
|
60
|
+
// 孤立目录:目录存在但 git 不认识,先删除再重建
|
|
61
|
+
console.warn(`[Crew] Orphaned worktree dir, removing: ${wtDir}`);
|
|
62
|
+
await fs.rm(wtDir, { recursive: true, force: true }).catch(() => {});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
// 创建分支(如果不存在)
|
|
68
|
+
try {
|
|
69
|
+
await execFile('git', ['branch', branch], { cwd: projectDir });
|
|
70
|
+
} catch {
|
|
71
|
+
// 分支已存在,忽略
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 创建 worktree
|
|
75
|
+
await execFile('git', ['worktree', 'add', wtDir, branch], { cwd: projectDir });
|
|
76
|
+
console.log(`[Crew] Created worktree: ${wtDir} on branch ${branch}`);
|
|
77
|
+
worktreeMap.set(idx, wtDir);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
console.error(`[Crew] Failed to create worktree for group ${idx}:`, e.message);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return worktreeMap;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 清理 session 的 git worktrees
|
|
88
|
+
* @param {string} projectDir - 主项目目录
|
|
89
|
+
*/
|
|
90
|
+
export async function cleanupWorktrees(projectDir) {
|
|
91
|
+
const worktreeBase = join(projectDir, '.worktrees');
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
await fs.access(worktreeBase);
|
|
95
|
+
} catch {
|
|
96
|
+
return; // .worktrees 目录不存在,无需清理
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const entries = await fs.readdir(worktreeBase);
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
if (!entry.startsWith('dev-')) continue;
|
|
103
|
+
const wtDir = join(worktreeBase, entry);
|
|
104
|
+
const branch = `crew/${entry}`;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
await execFile('git', ['worktree', 'remove', wtDir, '--force'], { cwd: projectDir });
|
|
108
|
+
console.log(`[Crew] Removed worktree: ${wtDir}`);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
console.warn(`[Crew] Failed to remove worktree ${wtDir}:`, e.message);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
await execFile('git', ['branch', '-D', branch], { cwd: projectDir });
|
|
115
|
+
console.log(`[Crew] Deleted branch: ${branch}`);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
console.warn(`[Crew] Failed to delete branch ${branch}:`, e.message);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 尝试删除 .worktrees 目录(如果已空)
|
|
122
|
+
try {
|
|
123
|
+
await fs.rmdir(worktreeBase);
|
|
124
|
+
} catch {
|
|
125
|
+
// 目录不空或其他原因,忽略
|
|
126
|
+
}
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.error(`[Crew] Failed to cleanup worktrees:`, e.message);
|
|
129
|
+
}
|
|
130
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yeaft/webchat-agent",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.236",
|
|
4
4
|
"description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -33,8 +33,12 @@
|
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"*.js",
|
|
36
|
+
"connection/",
|
|
37
|
+
"crew/",
|
|
36
38
|
"sdk/",
|
|
37
|
-
"scripts/"
|
|
39
|
+
"scripts/",
|
|
40
|
+
"service/",
|
|
41
|
+
"workbench/"
|
|
38
42
|
],
|
|
39
43
|
"license": "MIT",
|
|
40
44
|
"author": "Yeaft",
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service — shared configuration and utility functions
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
|
|
5
|
+
import { join, dirname } from 'path';
|
|
6
|
+
import { platform, homedir } from 'os';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
export const SERVICE_NAME = 'yeaft-agent';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Load .env file from agent directory (or cwd) into process.env
|
|
14
|
+
* Only sets vars that are not already set (won't override existing env)
|
|
15
|
+
*/
|
|
16
|
+
function loadDotenv() {
|
|
17
|
+
// Try agent source directory first, then cwd
|
|
18
|
+
const agentDir = join(__dirname, '..');
|
|
19
|
+
const candidates = [join(agentDir, '.env'), join(process.cwd(), '.env')];
|
|
20
|
+
for (const envPath of candidates) {
|
|
21
|
+
if (!existsSync(envPath)) continue;
|
|
22
|
+
try {
|
|
23
|
+
const content = readFileSync(envPath, 'utf-8');
|
|
24
|
+
for (const line of content.split('\n')) {
|
|
25
|
+
const match = line.match(/^\s*([^#][^=]*)\s*=\s*(.*)$/);
|
|
26
|
+
if (match) {
|
|
27
|
+
const key = match[1].trim();
|
|
28
|
+
let value = match[2].trim();
|
|
29
|
+
value = value.replace(/^["']|["']$/g, '');
|
|
30
|
+
// Don't override existing env vars
|
|
31
|
+
if (!process.env[key]) {
|
|
32
|
+
process.env[key] = value;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return; // loaded successfully, stop
|
|
37
|
+
} catch {
|
|
38
|
+
// continue to next candidate
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Standard config/log directory per platform
|
|
44
|
+
export function getConfigDir() {
|
|
45
|
+
if (platform() === 'win32') {
|
|
46
|
+
return join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), SERVICE_NAME);
|
|
47
|
+
}
|
|
48
|
+
return join(homedir(), '.config', SERVICE_NAME);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getLogDir() {
|
|
52
|
+
return join(getConfigDir(), 'logs');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function getConfigPath() {
|
|
56
|
+
return join(getConfigDir(), 'config.json');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Save agent configuration to standard location */
|
|
60
|
+
export function saveServiceConfig(config) {
|
|
61
|
+
const dir = getConfigDir();
|
|
62
|
+
mkdirSync(dir, { recursive: true });
|
|
63
|
+
mkdirSync(getLogDir(), { recursive: true });
|
|
64
|
+
writeFileSync(getConfigPath(), JSON.stringify(config, null, 2));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Load agent configuration from standard location */
|
|
68
|
+
export function loadServiceConfig() {
|
|
69
|
+
const configPath = getConfigPath();
|
|
70
|
+
if (!existsSync(configPath)) return null;
|
|
71
|
+
try {
|
|
72
|
+
return JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Resolve the full path to the node binary */
|
|
79
|
+
export function getNodePath() {
|
|
80
|
+
return process.execPath;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Resolve the full path to cli.js */
|
|
84
|
+
export function getCliPath() {
|
|
85
|
+
return join(__dirname, '..', 'cli.js');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse --server/--name/--secret/--work-dir from args, merge with existing config
|
|
90
|
+
*/
|
|
91
|
+
export function parseServiceArgs(args) {
|
|
92
|
+
// Load .env if available (for dev / source-based usage)
|
|
93
|
+
loadDotenv();
|
|
94
|
+
|
|
95
|
+
const existing = loadServiceConfig() || {};
|
|
96
|
+
const config = {
|
|
97
|
+
serverUrl: existing.serverUrl || '',
|
|
98
|
+
agentName: existing.agentName || '',
|
|
99
|
+
agentSecret: existing.agentSecret || '',
|
|
100
|
+
workDir: existing.workDir || '',
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Environment variables override saved config
|
|
104
|
+
if (process.env.SERVER_URL) config.serverUrl = process.env.SERVER_URL;
|
|
105
|
+
if (process.env.AGENT_NAME) config.agentName = process.env.AGENT_NAME;
|
|
106
|
+
if (process.env.AGENT_SECRET) config.agentSecret = process.env.AGENT_SECRET;
|
|
107
|
+
if (process.env.WORK_DIR) config.workDir = process.env.WORK_DIR;
|
|
108
|
+
|
|
109
|
+
// CLI args override everything
|
|
110
|
+
for (let i = 0; i < args.length; i++) {
|
|
111
|
+
const arg = args[i];
|
|
112
|
+
const next = args[i + 1];
|
|
113
|
+
switch (arg) {
|
|
114
|
+
case '--server': if (next) { config.serverUrl = next; i++; } break;
|
|
115
|
+
case '--name': if (next) { config.agentName = next; i++; } break;
|
|
116
|
+
case '--secret': if (next) { config.agentSecret = next; i++; } break;
|
|
117
|
+
case '--work-dir': if (next) { config.workDir = next; i++; } break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return config;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function validateConfig(config) {
|
|
125
|
+
if (!config.serverUrl) {
|
|
126
|
+
console.error('Error: --server <url> is required');
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
if (!config.agentSecret) {
|
|
130
|
+
console.error('Error: --secret <secret> is required');
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
}
|
package/service/index.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service — platform dispatcher
|
|
3
|
+
* Routes install/uninstall/start/stop/restart/status/logs to the correct platform module.
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync } from 'fs';
|
|
6
|
+
import { platform } from 'os';
|
|
7
|
+
import {
|
|
8
|
+
SERVICE_NAME, getConfigDir, getLogDir, getConfigPath,
|
|
9
|
+
saveServiceConfig, loadServiceConfig,
|
|
10
|
+
parseServiceArgs, validateConfig
|
|
11
|
+
} from './config.js';
|
|
12
|
+
import { getSystemdServicePath, linuxInstall, linuxUninstall, linuxStart, linuxStop, linuxRestart, linuxStatus, linuxLogs } from './linux.js';
|
|
13
|
+
import { getLaunchdPlistPath, macInstall, macUninstall, macStart, macStop, macRestart, macStatus, macLogs } from './macos.js';
|
|
14
|
+
import { winInstall, winUninstall, winStart, winStop, winRestart, winStatus, winLogs } from './windows.js';
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
getConfigDir, getLogDir, getConfigPath,
|
|
18
|
+
saveServiceConfig, loadServiceConfig,
|
|
19
|
+
parseServiceArgs
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const os = platform();
|
|
23
|
+
|
|
24
|
+
function ensureInstalled() {
|
|
25
|
+
if (os === 'linux') {
|
|
26
|
+
if (!existsSync(getSystemdServicePath())) {
|
|
27
|
+
console.error('Service not installed. Run "yeaft-agent install" first.');
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
} else if (os === 'darwin') {
|
|
31
|
+
if (!existsSync(getLaunchdPlistPath())) {
|
|
32
|
+
console.error('Service not installed. Run "yeaft-agent install" first.');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Windows check is done inside individual functions
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function install(args) {
|
|
40
|
+
const config = parseServiceArgs(args);
|
|
41
|
+
validateConfig(config);
|
|
42
|
+
saveServiceConfig(config);
|
|
43
|
+
|
|
44
|
+
console.log(`Installing ${SERVICE_NAME} service...`);
|
|
45
|
+
console.log(` Server: ${config.serverUrl}`);
|
|
46
|
+
console.log(` Name: ${config.agentName || '(auto)'}`);
|
|
47
|
+
console.log(` WorkDir: ${config.workDir || '(home)'}`);
|
|
48
|
+
console.log('');
|
|
49
|
+
|
|
50
|
+
if (os === 'linux') linuxInstall(config);
|
|
51
|
+
else if (os === 'darwin') macInstall(config);
|
|
52
|
+
else if (os === 'win32') winInstall(config);
|
|
53
|
+
else {
|
|
54
|
+
console.error(`Unsupported platform: ${os}`);
|
|
55
|
+
console.log('You can run the agent directly: yeaft-agent --server <url> --secret <secret>');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function uninstall() {
|
|
61
|
+
console.log(`Uninstalling ${SERVICE_NAME} service...`);
|
|
62
|
+
if (os === 'linux') linuxUninstall();
|
|
63
|
+
else if (os === 'darwin') macUninstall();
|
|
64
|
+
else if (os === 'win32') winUninstall();
|
|
65
|
+
else { console.error(`Unsupported platform: ${os}`); process.exit(1); }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function start() {
|
|
69
|
+
ensureInstalled();
|
|
70
|
+
if (os === 'linux') linuxStart();
|
|
71
|
+
else if (os === 'darwin') macStart();
|
|
72
|
+
else if (os === 'win32') winStart();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function stop() {
|
|
76
|
+
ensureInstalled();
|
|
77
|
+
if (os === 'linux') linuxStop();
|
|
78
|
+
else if (os === 'darwin') macStop();
|
|
79
|
+
else if (os === 'win32') winStop();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function restart() {
|
|
83
|
+
ensureInstalled();
|
|
84
|
+
if (os === 'linux') linuxRestart();
|
|
85
|
+
else if (os === 'darwin') macRestart();
|
|
86
|
+
else if (os === 'win32') winRestart();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function status() {
|
|
90
|
+
if (os === 'linux') linuxStatus();
|
|
91
|
+
else if (os === 'darwin') macStatus();
|
|
92
|
+
else if (os === 'win32') winStatus();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function logs() {
|
|
96
|
+
if (os === 'linux') linuxLogs();
|
|
97
|
+
else if (os === 'darwin') macLogs();
|
|
98
|
+
else if (os === 'win32') winLogs();
|
|
99
|
+
}
|