xiaozuoassistant 0.2.20 → 0.2.22
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 +119 -119
- package/bin/cli.js +896 -896
- package/config.json +41 -41
- package/dist/client/assets/{browser-ponyfill-hr4BsUsr.js → browser-ponyfill-Bcpejndl.js} +1 -1
- package/dist/client/assets/index-BfvHy-SS.js +201 -0
- package/dist/client/favicon.svg +4 -4
- package/dist/client/index.html +13 -13
- package/dist/client/locales/en/translation.json +110 -110
- package/dist/client/locales/zh/translation.json +112 -112
- package/dist/server/agents/office.js +5 -5
- package/dist/server/channels/feishu.js +18 -3
- package/dist/server/config/prompts.js +11 -11
- package/dist/server/core/brain.js +2 -2
- package/dist/server/core/memories/manager.js +9 -9
- package/dist/server/core/memories/short-term.js +3 -1
- package/dist/server/core/memories/structured.js +93 -93
- package/dist/server/core/task-queue.js +4 -4
- package/dist/server/index.js +18 -2
- package/package.json +116 -116
- package/public/favicon.svg +4 -4
- package/public/locales/en/translation.json +110 -110
- package/public/locales/zh/translation.json +112 -112
- package/scripts/init-app-home.cjs +43 -43
- package/dist/client/assets/index-Bn8oA3Sc.js +0 -201
package/bin/cli.js
CHANGED
|
@@ -1,896 +1,896 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { spawn } from 'child_process';
|
|
4
|
-
import path from 'path';
|
|
5
|
-
import { fileURLToPath } from 'url';
|
|
6
|
-
import fs from 'fs';
|
|
7
|
-
import os from 'os';
|
|
8
|
-
import { createGzip } from 'zlib';
|
|
9
|
-
import { pipeline } from 'stream';
|
|
10
|
-
import { promisify } from 'util';
|
|
11
|
-
import * as tar from 'tar';
|
|
12
|
-
import http from 'http';
|
|
13
|
-
import https from 'https';
|
|
14
|
-
import net from 'net';
|
|
15
|
-
|
|
16
|
-
const pipe = promisify(pipeline);
|
|
17
|
-
|
|
18
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
19
|
-
const __dirname = path.dirname(__filename);
|
|
20
|
-
|
|
21
|
-
// Determine the root directory of the package
|
|
22
|
-
// When running from source: bin/cli.js -> ../
|
|
23
|
-
// When installed globally: lib/node_modules/xiaozuoassistant/bin/cli.js -> ../
|
|
24
|
-
const packageRoot = path.resolve(__dirname, '..');
|
|
25
|
-
|
|
26
|
-
const binName = path.basename(process.argv[1] || 'xiaozuoAssistant');
|
|
27
|
-
|
|
28
|
-
const args = process.argv.slice(2);
|
|
29
|
-
const command = args[0];
|
|
30
|
-
const commandArgs = args.slice(1);
|
|
31
|
-
|
|
32
|
-
const EXPORT_FILENAME_PREFIX = 'xiaozuoAssistant-backup';
|
|
33
|
-
const PRODUCTION_PORT = 3021; // 生产环境(CLI)默认端口
|
|
34
|
-
const DEV_PORT = 3001; // 开发环境默认端口
|
|
35
|
-
|
|
36
|
-
// Helper to get user's current working directory where they ran the command
|
|
37
|
-
// This is kept only for display/backward compatibility; runtime data is stored in APP_HOME.
|
|
38
|
-
const CWD = process.cwd();
|
|
39
|
-
|
|
40
|
-
function getAppHome() {
|
|
41
|
-
const fromFlag = getFlagValue('--home');
|
|
42
|
-
const fromEnv = process.env.XIAOZUOASSISTANT_HOME;
|
|
43
|
-
const base = (fromFlag || fromEnv || path.join(os.homedir(), '.xiaozuoassistant')).trim();
|
|
44
|
-
return path.resolve(base);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const APP_HOME = getAppHome();
|
|
48
|
-
|
|
49
|
-
// Unified runtime data directory
|
|
50
|
-
const DATA_PATHS = [
|
|
51
|
-
'config.json',
|
|
52
|
-
'memories',
|
|
53
|
-
'data',
|
|
54
|
-
'logs',
|
|
55
|
-
'sessions',
|
|
56
|
-
'workspace',
|
|
57
|
-
'plugins'
|
|
58
|
-
];
|
|
59
|
-
|
|
60
|
-
function ensureAppHome() {
|
|
61
|
-
try {
|
|
62
|
-
fs.mkdirSync(APP_HOME, { recursive: true });
|
|
63
|
-
} catch (e) {
|
|
64
|
-
console.error(`[CLI] 无法创建数据目录:${APP_HOME}`);
|
|
65
|
-
console.error(e);
|
|
66
|
-
process.exit(1);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function ensureDefaultConfig() {
|
|
71
|
-
const configPath = path.join(APP_HOME, 'config.json');
|
|
72
|
-
|
|
73
|
-
// 如果配置文件已存在,检查是否需要迁移端口(例如从 3001 迁移到 3021)
|
|
74
|
-
if (fs.existsSync(configPath)) {
|
|
75
|
-
try {
|
|
76
|
-
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
77
|
-
const config = JSON.parse(configContent);
|
|
78
|
-
// 如果端口是旧的默认值 3001,自动更新为 3021
|
|
79
|
-
if (config.server && config.server.port === DEV_PORT) {
|
|
80
|
-
console.log(`[CLI] 检测到旧端口配置 (${DEV_PORT}),正在迁移到生产环境端口 (${PRODUCTION_PORT})...`);
|
|
81
|
-
config.server.port = PRODUCTION_PORT;
|
|
82
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
83
|
-
console.log(`[CLI] ✅ 端口已更新为 ${PRODUCTION_PORT}`);
|
|
84
|
-
}
|
|
85
|
-
} catch (e) {
|
|
86
|
-
console.warn('[CLI] 检查现有配置失败:', e);
|
|
87
|
-
}
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// 如果配置文件不存在,创建新的默认配置
|
|
92
|
-
const templatePath = path.join(packageRoot, 'config.json');
|
|
93
|
-
try {
|
|
94
|
-
if (fs.existsSync(templatePath)) {
|
|
95
|
-
// 复制模板,但强制修改端口为 3021
|
|
96
|
-
const templateContent = fs.readFileSync(templatePath, 'utf-8');
|
|
97
|
-
const templateConfig = JSON.parse(templateContent);
|
|
98
|
-
|
|
99
|
-
if (!templateConfig.server) templateConfig.server = {};
|
|
100
|
-
templateConfig.server.port = PRODUCTION_PORT;
|
|
101
|
-
|
|
102
|
-
fs.writeFileSync(configPath, JSON.stringify(templateConfig, null, 2));
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
} catch (e) {
|
|
106
|
-
// ignore
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const fallback = {
|
|
110
|
-
server: { port: PRODUCTION_PORT, host: 'localhost' },
|
|
111
|
-
llm: { apiKey: '', baseURL: '', model: '', temperature: 0.7 },
|
|
112
|
-
logging: { level: 'info' },
|
|
113
|
-
channels: {},
|
|
114
|
-
systemPrompt: ''
|
|
115
|
-
};
|
|
116
|
-
try {
|
|
117
|
-
fs.writeFileSync(configPath, JSON.stringify(fallback, null, 2));
|
|
118
|
-
} catch (e) {
|
|
119
|
-
console.error(`[CLI] 无法写入默认配置:${configPath}`);
|
|
120
|
-
console.error(e);
|
|
121
|
-
process.exit(1);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function ensureDefaultDirs() {
|
|
126
|
-
for (const dir of ['logs', 'data', 'memories', 'sessions', 'workspace']) {
|
|
127
|
-
try {
|
|
128
|
-
fs.mkdirSync(path.join(APP_HOME, dir), { recursive: true });
|
|
129
|
-
} catch {
|
|
130
|
-
// ignore
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function getPortFromConfig() {
|
|
136
|
-
let port = PRODUCTION_PORT;
|
|
137
|
-
const configPath = path.join(APP_HOME, 'config.json');
|
|
138
|
-
if (fs.existsSync(configPath)) {
|
|
139
|
-
try {
|
|
140
|
-
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
141
|
-
if (config.server && config.server.port) {
|
|
142
|
-
port = config.server.port;
|
|
143
|
-
}
|
|
144
|
-
} catch (e) {
|
|
145
|
-
// ignore
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
return port;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function getPidFilePath() {
|
|
152
|
-
return path.join(APP_HOME, 'logs', 'server.pid');
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function hasFlag(flag) {
|
|
156
|
-
return commandArgs.includes(flag);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function getFlagValue(name) {
|
|
160
|
-
const idx = commandArgs.findIndex(a => a === name);
|
|
161
|
-
if (idx < 0) return null;
|
|
162
|
-
const next = commandArgs[idx + 1];
|
|
163
|
-
if (!next || next.startsWith('-')) return null;
|
|
164
|
-
return next;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function getRegistry() {
|
|
168
|
-
if (hasFlag('-cn')) {
|
|
169
|
-
return 'https://registry.npmmirror.com';
|
|
170
|
-
} else if (hasFlag('-en')) {
|
|
171
|
-
return 'https://registry.npmjs.org';
|
|
172
|
-
}
|
|
173
|
-
return getFlagValue('--registry') || process.env.npm_config_registry || 'https://registry.npmjs.org';
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function isProcessRunning(pid) {
|
|
177
|
-
try {
|
|
178
|
-
process.kill(pid, 0);
|
|
179
|
-
return true;
|
|
180
|
-
} catch (e) {
|
|
181
|
-
return false;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
async function isPortOpen(port) {
|
|
186
|
-
return await new Promise((resolve) => {
|
|
187
|
-
const socket = net.connect({ host: '127.0.0.1', port }, () => {
|
|
188
|
-
socket.destroy();
|
|
189
|
-
resolve(true);
|
|
190
|
-
});
|
|
191
|
-
socket.on('error', () => {
|
|
192
|
-
resolve(false);
|
|
193
|
-
});
|
|
194
|
-
socket.setTimeout(500, () => {
|
|
195
|
-
socket.destroy();
|
|
196
|
-
resolve(false);
|
|
197
|
-
});
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async function killProcessTree(pid) {
|
|
202
|
-
if (process.platform === 'win32') {
|
|
203
|
-
try {
|
|
204
|
-
await runCommand('taskkill', ['/pid', pid.toString(), '/T', '/F'], { stdio: 'ignore' });
|
|
205
|
-
return true;
|
|
206
|
-
} catch {
|
|
207
|
-
return false;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Unix-like implementation
|
|
212
|
-
const tryKill = (targetPid, signal) => {
|
|
213
|
-
try {
|
|
214
|
-
process.kill(targetPid, signal);
|
|
215
|
-
return true;
|
|
216
|
-
} catch (e) {
|
|
217
|
-
return false;
|
|
218
|
-
}
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
tryKill(-pid, 'SIGTERM');
|
|
222
|
-
tryKill(pid, 'SIGTERM');
|
|
223
|
-
|
|
224
|
-
// Wait a bit
|
|
225
|
-
await new Promise(r => setTimeout(r, 500));
|
|
226
|
-
|
|
227
|
-
if (isProcessRunning(pid)) {
|
|
228
|
-
tryKill(-pid, 'SIGKILL');
|
|
229
|
-
tryKill(pid, 'SIGKILL');
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
return !isProcessRunning(pid);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
async function stopServer() {
|
|
236
|
-
const pidFile = getPidFilePath();
|
|
237
|
-
const port = getPortFromConfig();
|
|
238
|
-
|
|
239
|
-
const killByPort = async () => {
|
|
240
|
-
if (process.platform === 'win32') {
|
|
241
|
-
// Windows implementation using netstat
|
|
242
|
-
try {
|
|
243
|
-
const netstat = spawn('netstat', ['-ano'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
244
|
-
let output = '';
|
|
245
|
-
netstat.stdout.on('data', d => output += d.toString());
|
|
246
|
-
await new Promise(r => netstat.on('close', r));
|
|
247
|
-
|
|
248
|
-
const lines = output.split('\n');
|
|
249
|
-
const pids = new Set();
|
|
250
|
-
for (const line of lines) {
|
|
251
|
-
if (line.includes(`:${port}`)) {
|
|
252
|
-
const parts = line.trim().split(/\s+/);
|
|
253
|
-
const pid = parts[parts.length - 1];
|
|
254
|
-
if (pid && !isNaN(Number(pid)) && Number(pid) > 0) {
|
|
255
|
-
pids.add(Number(pid));
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
if (pids.size === 0) return false;
|
|
261
|
-
|
|
262
|
-
for (const pid of pids) {
|
|
263
|
-
await killProcessTree(pid);
|
|
264
|
-
}
|
|
265
|
-
return true;
|
|
266
|
-
} catch (e) {
|
|
267
|
-
console.error('[CLI] Windows port kill failed:', e);
|
|
268
|
-
return false;
|
|
269
|
-
}
|
|
270
|
-
} else {
|
|
271
|
-
// Unix implementation using lsof
|
|
272
|
-
const lsof = spawn('lsof', ['-ti', `tcp:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
273
|
-
let output = '';
|
|
274
|
-
lsof.stdout.on('data', (d) => { output += d.toString(); });
|
|
275
|
-
await new Promise((resolve) => lsof.on('close', resolve));
|
|
276
|
-
|
|
277
|
-
const pids = output
|
|
278
|
-
.split(/\s+/)
|
|
279
|
-
.map(s => s.trim())
|
|
280
|
-
.filter(Boolean)
|
|
281
|
-
.map(Number)
|
|
282
|
-
.filter(n => Number.isFinite(n) && n > 0);
|
|
283
|
-
|
|
284
|
-
if (pids.length === 0) return false;
|
|
285
|
-
|
|
286
|
-
for (const pid of pids) {
|
|
287
|
-
await killProcessTree(pid);
|
|
288
|
-
}
|
|
289
|
-
return true;
|
|
290
|
-
}
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
if (fs.existsSync(pidFile)) {
|
|
294
|
-
const pidStr = fs.readFileSync(pidFile, 'utf-8').trim();
|
|
295
|
-
const pid = Number(pidStr);
|
|
296
|
-
|
|
297
|
-
// ... existing pid checks ...
|
|
298
|
-
if (!Number.isFinite(pid) || pid <= 0) {
|
|
299
|
-
fs.unlinkSync(pidFile);
|
|
300
|
-
console.log('[CLI] 未找到可用的 PID(已清理 pid 文件)。');
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
if (!isProcessRunning(pid)) {
|
|
305
|
-
fs.unlinkSync(pidFile);
|
|
306
|
-
console.log('[CLI] 服务未在运行(已清理 pid 文件)。');
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
console.log(`[CLI] 正在停止服务(PID: ${pid})...`);
|
|
311
|
-
await killProcessTree(pid);
|
|
312
|
-
await killByPort();
|
|
313
|
-
try { fs.unlinkSync(pidFile); } catch (e) {}
|
|
314
|
-
|
|
315
|
-
if (!(await isPortOpen(port))) {
|
|
316
|
-
console.log('[CLI] ✅ 服务已停止。');
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// Fallback message
|
|
321
|
-
console.error('[CLI] ❌ 停止失败:端口仍在占用。');
|
|
322
|
-
if (process.platform !== 'win32') {
|
|
323
|
-
console.error(`[CLI] 你可以手动执行:lsof -ti tcp:${port} | xargs kill -9`);
|
|
324
|
-
} else {
|
|
325
|
-
console.error(`[CLI] 你可以手动执行:netstat -ano | findstr :${port} 并 kill 对应 PID`);
|
|
326
|
-
}
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
console.log('[CLI] 未找到 pid 文件,尝试按端口停止...');
|
|
331
|
-
const ok = await killByPort();
|
|
332
|
-
if (!ok) {
|
|
333
|
-
console.log(`[CLI] 未发现占用端口 ${port} 的进程。`);
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
if (!(await isPortOpen(port))) {
|
|
337
|
-
console.log('[CLI] ✅ 服务已停止。');
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
console.error('[CLI] ❌ 停止失败:端口仍在占用。');
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
async function runCommand(cmd, cmdArgs, options = {}) {
|
|
344
|
-
// On Windows, use npm.cmd instead of npm
|
|
345
|
-
const command = process.platform === 'win32' && cmd === 'npm' ? 'npm.cmd' : cmd;
|
|
346
|
-
// For Windows, use shell: true to ensure commands are executed correctly
|
|
347
|
-
const child = spawn(command, cmdArgs, { stdio: 'inherit', shell: process.platform === 'win32', ...options });
|
|
348
|
-
const code = await new Promise((resolve) => child.on('close', resolve));
|
|
349
|
-
return typeof code === 'number' ? code : 1;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
async function runWithSudoIfNeeded(cmd, cmdArgs, options = {}) {
|
|
353
|
-
const code = await runCommand(cmd, cmdArgs, options);
|
|
354
|
-
if (code === 0) return 0;
|
|
355
|
-
|
|
356
|
-
const canSudo = process.platform !== 'win32' && process.stdin.isTTY;
|
|
357
|
-
if (!canSudo) return code;
|
|
358
|
-
|
|
359
|
-
console.log('[CLI] 权限不足,尝试使用 sudo 继续...');
|
|
360
|
-
return await runCommand('sudo', [cmd, ...cmdArgs], options);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
async function getRemoteVersion(registry) {
|
|
364
|
-
return new Promise((resolve, reject) => {
|
|
365
|
-
const options = {
|
|
366
|
-
hostname: registry.replace('https://', ''),
|
|
367
|
-
path: '/xiaozuoassistant/latest',
|
|
368
|
-
method: 'GET',
|
|
369
|
-
headers: {
|
|
370
|
-
'Content-Type': 'application/json'
|
|
371
|
-
}
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
const req = https.request(options, (res) => {
|
|
375
|
-
let data = '';
|
|
376
|
-
res.on('data', (chunk) => {
|
|
377
|
-
data += chunk;
|
|
378
|
-
});
|
|
379
|
-
res.on('end', () => {
|
|
380
|
-
try {
|
|
381
|
-
const info = JSON.parse(data);
|
|
382
|
-
resolve(info.version);
|
|
383
|
-
} catch (e) {
|
|
384
|
-
reject(new Error('Failed to parse remote version info'));
|
|
385
|
-
}
|
|
386
|
-
});
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
req.on('error', (e) => {
|
|
390
|
-
reject(e);
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
req.end();
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
function compareVersions(version1, version2) {
|
|
398
|
-
const v1 = version1.split('.').map(Number);
|
|
399
|
-
const v2 = version2.split('.').map(Number);
|
|
400
|
-
for (let i = 0; i < Math.max(v1.length, v2.length); i++) {
|
|
401
|
-
const num1 = v1[i] || 0;
|
|
402
|
-
const num2 = v2[i] || 0;
|
|
403
|
-
if (num1 > num2) return 1;
|
|
404
|
-
if (num1 < num2) return -1;
|
|
405
|
-
}
|
|
406
|
-
return 0;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
async function updateApp() {
|
|
410
|
-
const registry = getRegistry();
|
|
411
|
-
const port = getPortFromConfig();
|
|
412
|
-
const pidFile = getPidFilePath();
|
|
413
|
-
|
|
414
|
-
// Get local version
|
|
415
|
-
const packageJsonPath = path.join(__dirname, '..', 'package.json');
|
|
416
|
-
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf-8');
|
|
417
|
-
const packageJson = JSON.parse(packageJsonContent);
|
|
418
|
-
const localVersion = packageJson.version;
|
|
419
|
-
console.log(`[CLI] 当前版本: ${localVersion}`);
|
|
420
|
-
|
|
421
|
-
// Get remote version
|
|
422
|
-
try {
|
|
423
|
-
console.log(`[CLI] 检查远程版本(registry=${registry})...`);
|
|
424
|
-
const remoteVersion = await getRemoteVersion(registry);
|
|
425
|
-
console.log(`[CLI] 远程版本: ${remoteVersion}`);
|
|
426
|
-
|
|
427
|
-
// Compare versions
|
|
428
|
-
const comparison = compareVersions(localVersion, remoteVersion);
|
|
429
|
-
if (comparison >= 0) {
|
|
430
|
-
console.log('[CLI] ✅ 已经是最新版本,无需更新。');
|
|
431
|
-
process.exit(0);
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
console.log(`[CLI] 发现新版本 ${remoteVersion},开始更新...`);
|
|
435
|
-
} catch (e) {
|
|
436
|
-
console.warn('[CLI] ⚠️ 版本检查失败,继续执行更新:', e);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
const runningByPid = fs.existsSync(pidFile) && (() => {
|
|
440
|
-
try {
|
|
441
|
-
const pid = Number(fs.readFileSync(pidFile, 'utf-8').trim());
|
|
442
|
-
return Number.isFinite(pid) && pid > 0 && isProcessRunning(pid);
|
|
443
|
-
} catch {
|
|
444
|
-
return false;
|
|
445
|
-
}
|
|
446
|
-
})();
|
|
447
|
-
const runningByPort = await isPortOpen(port);
|
|
448
|
-
const wasRunning = runningByPid || runningByPort;
|
|
449
|
-
|
|
450
|
-
if (wasRunning) {
|
|
451
|
-
console.log('[CLI] 检测到服务正在运行,更新前将自动停止...');
|
|
452
|
-
await stopServer();
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Backup user config before update
|
|
456
|
-
const configPath = path.join(APP_HOME, 'config.json');
|
|
457
|
-
const configBackupPath = path.join(APP_HOME, 'config.json.bak');
|
|
458
|
-
let configBackedUp = false;
|
|
459
|
-
if (fs.existsSync(configPath)) {
|
|
460
|
-
try {
|
|
461
|
-
fs.copyFileSync(configPath, configBackupPath);
|
|
462
|
-
configBackedUp = true;
|
|
463
|
-
console.log('[CLI] 用户配置已备份。');
|
|
464
|
-
} catch (e) {
|
|
465
|
-
console.warn('[CLI] ⚠️ 用户配置备份失败:', e);
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
console.log(`[CLI] 正在更新 xiaozuoassistant(registry=${registry})...`);
|
|
470
|
-
const code = await runWithSudoIfNeeded('npm', ['install', '-g', 'xiaozuoassistant@latest', `--registry=${registry}`]);
|
|
471
|
-
|
|
472
|
-
// Restore user config after update
|
|
473
|
-
if (configBackedUp && fs.existsSync(configBackupPath)) {
|
|
474
|
-
try {
|
|
475
|
-
fs.copyFileSync(configBackupPath, configPath);
|
|
476
|
-
fs.unlinkSync(configBackupPath);
|
|
477
|
-
console.log('[CLI] 用户配置已恢复。');
|
|
478
|
-
} catch (e) {
|
|
479
|
-
console.warn('[CLI] ⚠️ 用户配置恢复失败:', e);
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
if (code !== 0) {
|
|
484
|
-
console.error('[CLI] ❌ 更新失败。');
|
|
485
|
-
if (wasRunning) {
|
|
486
|
-
console.log('[CLI] 更新失败,尝试恢复启动旧版本服务...');
|
|
487
|
-
await runCommand('node', [path.join(packageRoot, 'bin', 'cli.js'), 'start'], { cwd: APP_HOME });
|
|
488
|
-
}
|
|
489
|
-
process.exit(code);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
console.log('[CLI] ✅ 更新完成。');
|
|
493
|
-
|
|
494
|
-
if (wasRunning) {
|
|
495
|
-
console.log('[CLI] 正在自动重启服务...');
|
|
496
|
-
const restartCode = await runCommand('xiaozuoAssistant', ['start'], { cwd: APP_HOME });
|
|
497
|
-
process.exit(restartCode);
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
async function removeApp() {
|
|
502
|
-
const registry = getRegistry();
|
|
503
|
-
const targets = ['config.json', 'memories', 'data', 'logs', 'sessions', 'workspace']
|
|
504
|
-
.map(p => path.join(APP_HOME, p));
|
|
505
|
-
|
|
506
|
-
const doRemove = async () => {
|
|
507
|
-
await stopServer();
|
|
508
|
-
|
|
509
|
-
console.log('[CLI] 正在删除本地数据(当前目录)...');
|
|
510
|
-
for (const target of targets) {
|
|
511
|
-
try {
|
|
512
|
-
if (fs.existsSync(target)) {
|
|
513
|
-
fs.rmSync(target, { recursive: true, force: true });
|
|
514
|
-
console.log(`[CLI] 已删除:${target}`);
|
|
515
|
-
}
|
|
516
|
-
} catch (e) {
|
|
517
|
-
console.error(`[CLI] 删除失败:${target}`);
|
|
518
|
-
console.error(e);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
console.log('[CLI] 正在卸载全局包 xiaozuoassistant...');
|
|
523
|
-
const code = await runWithSudoIfNeeded('npm', ['uninstall', '-g', 'xiaozuoassistant', `--registry=${registry}`]);
|
|
524
|
-
if (code !== 0) {
|
|
525
|
-
console.error('[CLI] ❌ 卸载失败。');
|
|
526
|
-
process.exit(code);
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
console.log('[CLI] ✅ 已卸载并清理数据。');
|
|
530
|
-
};
|
|
531
|
-
|
|
532
|
-
if (hasFlag('--yes') || hasFlag('-y')) {
|
|
533
|
-
await doRemove();
|
|
534
|
-
return;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
console.log('[CLI] remove 将执行以下操作:');
|
|
538
|
-
console.log(' 1) 停止后台服务');
|
|
539
|
-
console.log(' 2) 删除当前目录下的数据:');
|
|
540
|
-
for (const target of targets) console.log(` - ${target}`);
|
|
541
|
-
console.log(' 3) 卸载全局 npm 包:xiaozuoassistant');
|
|
542
|
-
process.stdout.write('确认继续?(y/N) ');
|
|
543
|
-
process.stdin.once('data', async (data) => {
|
|
544
|
-
const answer = data.toString().trim().toLowerCase();
|
|
545
|
-
if (answer === 'y' || answer === 'yes') {
|
|
546
|
-
await doRemove();
|
|
547
|
-
} else {
|
|
548
|
-
console.log('已取消。');
|
|
549
|
-
}
|
|
550
|
-
process.exit(0);
|
|
551
|
-
});
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
function printVersion() {
|
|
555
|
-
try {
|
|
556
|
-
const pkgPath = path.join(packageRoot, 'package.json');
|
|
557
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
558
|
-
console.log(`${pkg.name}@${pkg.version}`);
|
|
559
|
-
console.log('Package Root:', packageRoot);
|
|
560
|
-
console.log('Node Version:', process.version);
|
|
561
|
-
} catch {
|
|
562
|
-
console.log('xiaozuoassistant@unknown');
|
|
563
|
-
console.log('Package Root:', packageRoot);
|
|
564
|
-
console.log('Node Version:', process.version);
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
async function exportData() {
|
|
569
|
-
ensureAppHome();
|
|
570
|
-
ensureDefaultDirs();
|
|
571
|
-
console.log('📦 Starting data export...');
|
|
572
|
-
|
|
573
|
-
// Generate filename with timestamp
|
|
574
|
-
const now = new Date();
|
|
575
|
-
const timestamp = now.toISOString().replace(/[-:T.]/g, '').slice(0, 14); // YYYYMMDDHHmmss
|
|
576
|
-
const exportFilename = `${EXPORT_FILENAME_PREFIX}-${timestamp}.tar.gz`;
|
|
577
|
-
|
|
578
|
-
const filesToArchive = [];
|
|
579
|
-
|
|
580
|
-
for (const p of DATA_PATHS) {
|
|
581
|
-
if (fs.existsSync(path.join(APP_HOME, p))) {
|
|
582
|
-
filesToArchive.push(p);
|
|
583
|
-
console.log(` - Found: ${p}`);
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
if (filesToArchive.length === 0) {
|
|
588
|
-
console.warn('⚠️ No data files found to export in current directory.');
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
const exportPath = path.join(APP_HOME, exportFilename);
|
|
593
|
-
|
|
594
|
-
try {
|
|
595
|
-
await tar.c(
|
|
596
|
-
{
|
|
597
|
-
gzip: true,
|
|
598
|
-
file: exportPath,
|
|
599
|
-
cwd: APP_HOME
|
|
600
|
-
},
|
|
601
|
-
filesToArchive
|
|
602
|
-
);
|
|
603
|
-
console.log(`✅ Export successful!`);
|
|
604
|
-
console.log(` Backup file: ${exportPath}`);
|
|
605
|
-
console.log(` Copy this file to your new machine to import.`);
|
|
606
|
-
} catch (err) {
|
|
607
|
-
console.error('❌ Export failed:', err);
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
async function importData() {
|
|
612
|
-
ensureAppHome();
|
|
613
|
-
|
|
614
|
-
// Find backup file
|
|
615
|
-
let backupFile = getFlagValue('--file');
|
|
616
|
-
|
|
617
|
-
if (!backupFile) {
|
|
618
|
-
// Try to find the latest backup file in APP_HOME
|
|
619
|
-
try {
|
|
620
|
-
const files = fs.readdirSync(APP_HOME)
|
|
621
|
-
.filter(f => f.startsWith(EXPORT_FILENAME_PREFIX) && f.endsWith('.tar.gz'))
|
|
622
|
-
.sort()
|
|
623
|
-
.reverse();
|
|
624
|
-
|
|
625
|
-
if (files.length > 0) {
|
|
626
|
-
backupFile = path.join(APP_HOME, files[0]);
|
|
627
|
-
console.log(`[CLI] No file specified, using latest found: ${files[0]}`);
|
|
628
|
-
} else {
|
|
629
|
-
// Fallback to old filename for compatibility
|
|
630
|
-
const oldFile = path.join(APP_HOME, 'xiaozuoAssistant-backup.tar.gz');
|
|
631
|
-
if (fs.existsSync(oldFile)) {
|
|
632
|
-
backupFile = oldFile;
|
|
633
|
-
console.log(`[CLI] Using legacy backup file: xiaozuoAssistant-backup.tar.gz`);
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
} catch (e) {
|
|
637
|
-
// ignore
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
if (!backupFile) {
|
|
642
|
-
console.error(`❌ No backup file found or specified.`);
|
|
643
|
-
console.log(` Use --file <path> to specify a backup file.`);
|
|
644
|
-
return;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// Resolve absolute path
|
|
648
|
-
const backupPath = path.resolve(process.cwd(), backupFile);
|
|
649
|
-
|
|
650
|
-
if (!fs.existsSync(backupPath)) {
|
|
651
|
-
console.error(`❌ Backup file not found: ${backupPath}`);
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
console.log(`📦 Starting data import from: ${backupPath}`);
|
|
656
|
-
console.log('⚠️ Warning: This will overwrite existing data files (config.json, memories, etc.)');
|
|
657
|
-
|
|
658
|
-
// Simple prompt implementation for Node.js
|
|
659
|
-
process.stdout.write(' Are you sure? (y/N) ');
|
|
660
|
-
process.stdin.once('data', async (data) => {
|
|
661
|
-
const answer = data.toString().trim().toLowerCase();
|
|
662
|
-
if (answer === 'y' || answer === 'yes') {
|
|
663
|
-
try {
|
|
664
|
-
// Stop server if running before import
|
|
665
|
-
const pidFile = getPidFilePath();
|
|
666
|
-
if (fs.existsSync(pidFile)) {
|
|
667
|
-
console.log('[CLI] Stopping server before import...');
|
|
668
|
-
await stopServer();
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
await tar.x({
|
|
672
|
-
file: backupPath,
|
|
673
|
-
cwd: APP_HOME
|
|
674
|
-
});
|
|
675
|
-
console.log('✅ Import successful! Data restored.');
|
|
676
|
-
|
|
677
|
-
// Auto-configure workspace path in config.json
|
|
678
|
-
const configPath = path.join(APP_HOME, 'config.json');
|
|
679
|
-
if (fs.existsSync(configPath)) {
|
|
680
|
-
try {
|
|
681
|
-
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
682
|
-
const config = JSON.parse(configContent);
|
|
683
|
-
|
|
684
|
-
// Update workspace to current directory
|
|
685
|
-
const oldWorkspace = config.workspace;
|
|
686
|
-
config.workspace = APP_HOME;
|
|
687
|
-
|
|
688
|
-
// Also update System Prompt if it contains the old workspace path
|
|
689
|
-
if (config.systemPrompt && typeof config.systemPrompt === 'string') {
|
|
690
|
-
if (oldWorkspace && config.systemPrompt.includes(oldWorkspace)) {
|
|
691
|
-
config.systemPrompt = config.systemPrompt.replace(oldWorkspace, APP_HOME);
|
|
692
|
-
} else if (config.systemPrompt.includes('Current Workspace:')) {
|
|
693
|
-
// Fallback regex replacement if exact string match fails
|
|
694
|
-
config.systemPrompt = config.systemPrompt.replace(/Current Workspace: .*/, `Current Workspace: ${APP_HOME}`);
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
// Ensure port is set to production port if it was dev port
|
|
699
|
-
if (config.server && config.server.port === DEV_PORT) {
|
|
700
|
-
config.server.port = PRODUCTION_PORT;
|
|
701
|
-
console.log(`[CLI] Updated port from ${DEV_PORT} to ${PRODUCTION_PORT}`);
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
705
|
-
console.log(`✅ Auto-configured workspace path to: ${APP_HOME}`);
|
|
706
|
-
} catch (e) {
|
|
707
|
-
console.warn('⚠️ Failed to auto-update config.json path:', e);
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
console.log(' You can now start the application.');
|
|
712
|
-
} catch (err) {
|
|
713
|
-
console.error('❌ Import failed:', err);
|
|
714
|
-
}
|
|
715
|
-
} else {
|
|
716
|
-
console.log(' Import cancelled.');
|
|
717
|
-
}
|
|
718
|
-
process.exit(0);
|
|
719
|
-
});
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
if (command === 'start') {
|
|
723
|
-
ensureAppHome();
|
|
724
|
-
ensureDefaultDirs();
|
|
725
|
-
ensureDefaultConfig();
|
|
726
|
-
console.log('Starting xiaozuoAssistant...');
|
|
727
|
-
|
|
728
|
-
const serverPath = path.join(packageRoot, 'dist', 'server', 'index.js');
|
|
729
|
-
|
|
730
|
-
// Ensure logs directory exists in APP_HOME
|
|
731
|
-
const logDir = path.join(APP_HOME, 'logs');
|
|
732
|
-
if (!fs.existsSync(logDir)) {
|
|
733
|
-
try {
|
|
734
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
735
|
-
} catch (e) {
|
|
736
|
-
console.error(`[CLI] Failed to create logs directory at ${logDir}:`, e);
|
|
737
|
-
process.exit(1);
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
const outLog = path.join(logDir, 'stdout.log');
|
|
742
|
-
const errLog = path.join(logDir, 'stderr.log');
|
|
743
|
-
|
|
744
|
-
console.log(`[CLI] Launching server from: ${serverPath}`);
|
|
745
|
-
console.log(`[CLI] Logs redirected to:`);
|
|
746
|
-
console.log(` - Stdout: ${outLog}`);
|
|
747
|
-
console.log(` - Stderr: ${errLog}`);
|
|
748
|
-
console.log(` - App Logs (Rotated): ${path.join(logDir, 'app.log')}`);
|
|
749
|
-
|
|
750
|
-
const port = getPortFromConfig();
|
|
751
|
-
const pidFile = getPidFilePath();
|
|
752
|
-
|
|
753
|
-
if (await isPortOpen(port)) {
|
|
754
|
-
console.error(`[CLI] 端口 ${port} 已被占用,可能服务已在运行。`);
|
|
755
|
-
console.error('[CLI] 可先执行:xiaozuoAssistant stop');
|
|
756
|
-
process.exit(1);
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
if (fs.existsSync(pidFile)) {
|
|
760
|
-
try {
|
|
761
|
-
const pid = Number(fs.readFileSync(pidFile, 'utf-8').trim());
|
|
762
|
-
if (Number.isFinite(pid) && pid > 0 && isProcessRunning(pid)) {
|
|
763
|
-
console.log(`[CLI] 服务已在运行(PID: ${pid})。`);
|
|
764
|
-
console.log(`[CLI] 访问地址: http://localhost:${port}`);
|
|
765
|
-
process.exit(0);
|
|
766
|
-
}
|
|
767
|
-
fs.unlinkSync(pidFile);
|
|
768
|
-
} catch (e) {
|
|
769
|
-
// ignore
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
console.log(`[CLI] Waiting for server to start on port ${port}...`);
|
|
774
|
-
|
|
775
|
-
try {
|
|
776
|
-
const out = fs.openSync(outLog, 'a');
|
|
777
|
-
const err = fs.openSync(errLog, 'a');
|
|
778
|
-
|
|
779
|
-
const child = spawn(process.execPath, [serverPath, ...args.slice(1)], {
|
|
780
|
-
detached: true, // Allow child to run independently
|
|
781
|
-
stdio: ['ignore', out, err], // Disconnect stdin, redirect stdout/stderr
|
|
782
|
-
cwd: APP_HOME, // Run in unified app home
|
|
783
|
-
env: {
|
|
784
|
-
...process.env,
|
|
785
|
-
NODE_ENV: 'production'
|
|
786
|
-
}
|
|
787
|
-
});
|
|
788
|
-
|
|
789
|
-
try {
|
|
790
|
-
fs.writeFileSync(pidFile, String(child.pid));
|
|
791
|
-
} catch (e) {
|
|
792
|
-
// ignore
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
let childExited = false;
|
|
796
|
-
child.on('exit', (code) => {
|
|
797
|
-
childExited = true;
|
|
798
|
-
console.error(`\n[CLI] Server process exited unexpectedly with code ${code}.`);
|
|
799
|
-
console.error(`[CLI] Check logs at ${errLog} or ${outLog}`);
|
|
800
|
-
process.exit(1);
|
|
801
|
-
});
|
|
802
|
-
|
|
803
|
-
// Health check function
|
|
804
|
-
const checkHealth = async (port, retries = 30) => {
|
|
805
|
-
const check = () => new Promise((resolve, reject) => {
|
|
806
|
-
const req = http.get(`http://localhost:${port}/api/health`, (res) => {
|
|
807
|
-
if (res.statusCode === 200) {
|
|
808
|
-
resolve(true);
|
|
809
|
-
} else {
|
|
810
|
-
reject(new Error(`Status ${res.statusCode}`));
|
|
811
|
-
}
|
|
812
|
-
res.resume();
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
req.on('error', (err) => reject(err));
|
|
816
|
-
req.setTimeout(500, () => {
|
|
817
|
-
req.destroy();
|
|
818
|
-
reject(new Error('Timeout'));
|
|
819
|
-
});
|
|
820
|
-
});
|
|
821
|
-
|
|
822
|
-
for (let i = 0; i < retries; i++) {
|
|
823
|
-
if (childExited) throw new Error('Child process exited');
|
|
824
|
-
try {
|
|
825
|
-
await check();
|
|
826
|
-
return true;
|
|
827
|
-
} catch (e) {
|
|
828
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
829
|
-
process.stdout.write('.');
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
throw new Error('Health check timed out');
|
|
833
|
-
};
|
|
834
|
-
|
|
835
|
-
// Perform health check
|
|
836
|
-
await checkHealth(port);
|
|
837
|
-
console.log(`\n[CLI] ✅ Health check passed!`);
|
|
838
|
-
|
|
839
|
-
// Unreference the child so the parent process can exit
|
|
840
|
-
child.unref();
|
|
841
|
-
|
|
842
|
-
console.log(`[CLI] Server running in background.`);
|
|
843
|
-
console.log(`[CLI] Access the Web UI at: http://localhost:${port}`);
|
|
844
|
-
process.exit(0);
|
|
845
|
-
} catch (error) {
|
|
846
|
-
try { if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile); } catch (e) {}
|
|
847
|
-
console.error(`\n[CLI] Failed to start server: ${error.message}`);
|
|
848
|
-
process.exit(1);
|
|
849
|
-
}
|
|
850
|
-
} else if (command === 'doctor') {
|
|
851
|
-
ensureAppHome();
|
|
852
|
-
ensureDefaultDirs();
|
|
853
|
-
console.log('Running doctor check...');
|
|
854
|
-
try {
|
|
855
|
-
const pkgPath = path.join(packageRoot, 'package.json');
|
|
856
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
857
|
-
console.log('Package Name:', pkg.name);
|
|
858
|
-
console.log('Package Version:', pkg.version);
|
|
859
|
-
} catch (e) {
|
|
860
|
-
console.log('Package Version: Unknown');
|
|
861
|
-
}
|
|
862
|
-
console.log('Package Root:', packageRoot);
|
|
863
|
-
console.log('Node Version:', process.version);
|
|
864
|
-
console.log('Doctor check complete.');
|
|
865
|
-
} else if (command === 'version') {
|
|
866
|
-
ensureAppHome();
|
|
867
|
-
printVersion();
|
|
868
|
-
} else if (command === 'update') {
|
|
869
|
-
updateApp();
|
|
870
|
-
} else if (command === 'remove') {
|
|
871
|
-
removeApp();
|
|
872
|
-
} else if (command === 'export') {
|
|
873
|
-
exportData();
|
|
874
|
-
} else if (command === 'import') {
|
|
875
|
-
importData();
|
|
876
|
-
} else if (command === 'stop') {
|
|
877
|
-
ensureAppHome();
|
|
878
|
-
stopServer();
|
|
879
|
-
} else {
|
|
880
|
-
console.log(`Usage: ${binName} <command>`);
|
|
881
|
-
console.log('Commands:');
|
|
882
|
-
console.log(' start Start the xiaozuoAssistant server');
|
|
883
|
-
console.log(' stop Stop the xiaozuoAssistant server');
|
|
884
|
-
console.log(' doctor Check the health and configuration');
|
|
885
|
-
console.log(' version Print package version and environment');
|
|
886
|
-
console.log(' update Update xiaozuoassistant and auto-restart if running');
|
|
887
|
-
console.log(' Options:');
|
|
888
|
-
console.log(' -cn Use Chinese npm registry (https://registry.npmmirror.com)');
|
|
889
|
-
console.log(' -en Use English npm registry (https://registry.npmjs.org)');
|
|
890
|
-
console.log(' remove Uninstall and delete data in current directory');
|
|
891
|
-
console.log(' export Backup local data (config, memories) to a file');
|
|
892
|
-
console.log(' import Restore data from a backup file');
|
|
893
|
-
console.log('');
|
|
894
|
-
console.log(`Data Dir: ${APP_HOME}`);
|
|
895
|
-
console.log(' Use --home <path> or env XIAOZUOASSISTANT_HOME to override.');
|
|
896
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import { createGzip } from 'zlib';
|
|
9
|
+
import { pipeline } from 'stream';
|
|
10
|
+
import { promisify } from 'util';
|
|
11
|
+
import * as tar from 'tar';
|
|
12
|
+
import http from 'http';
|
|
13
|
+
import https from 'https';
|
|
14
|
+
import net from 'net';
|
|
15
|
+
|
|
16
|
+
const pipe = promisify(pipeline);
|
|
17
|
+
|
|
18
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
19
|
+
const __dirname = path.dirname(__filename);
|
|
20
|
+
|
|
21
|
+
// Determine the root directory of the package
|
|
22
|
+
// When running from source: bin/cli.js -> ../
|
|
23
|
+
// When installed globally: lib/node_modules/xiaozuoassistant/bin/cli.js -> ../
|
|
24
|
+
const packageRoot = path.resolve(__dirname, '..');
|
|
25
|
+
|
|
26
|
+
const binName = path.basename(process.argv[1] || 'xiaozuoAssistant');
|
|
27
|
+
|
|
28
|
+
const args = process.argv.slice(2);
|
|
29
|
+
const command = args[0];
|
|
30
|
+
const commandArgs = args.slice(1);
|
|
31
|
+
|
|
32
|
+
const EXPORT_FILENAME_PREFIX = 'xiaozuoAssistant-backup';
|
|
33
|
+
const PRODUCTION_PORT = 3021; // 生产环境(CLI)默认端口
|
|
34
|
+
const DEV_PORT = 3001; // 开发环境默认端口
|
|
35
|
+
|
|
36
|
+
// Helper to get user's current working directory where they ran the command
|
|
37
|
+
// This is kept only for display/backward compatibility; runtime data is stored in APP_HOME.
|
|
38
|
+
const CWD = process.cwd();
|
|
39
|
+
|
|
40
|
+
function getAppHome() {
|
|
41
|
+
const fromFlag = getFlagValue('--home');
|
|
42
|
+
const fromEnv = process.env.XIAOZUOASSISTANT_HOME;
|
|
43
|
+
const base = (fromFlag || fromEnv || path.join(os.homedir(), '.xiaozuoassistant')).trim();
|
|
44
|
+
return path.resolve(base);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const APP_HOME = getAppHome();
|
|
48
|
+
|
|
49
|
+
// Unified runtime data directory
|
|
50
|
+
const DATA_PATHS = [
|
|
51
|
+
'config.json',
|
|
52
|
+
'memories',
|
|
53
|
+
'data',
|
|
54
|
+
'logs',
|
|
55
|
+
'sessions',
|
|
56
|
+
'workspace',
|
|
57
|
+
'plugins'
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
function ensureAppHome() {
|
|
61
|
+
try {
|
|
62
|
+
fs.mkdirSync(APP_HOME, { recursive: true });
|
|
63
|
+
} catch (e) {
|
|
64
|
+
console.error(`[CLI] 无法创建数据目录:${APP_HOME}`);
|
|
65
|
+
console.error(e);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ensureDefaultConfig() {
|
|
71
|
+
const configPath = path.join(APP_HOME, 'config.json');
|
|
72
|
+
|
|
73
|
+
// 如果配置文件已存在,检查是否需要迁移端口(例如从 3001 迁移到 3021)
|
|
74
|
+
if (fs.existsSync(configPath)) {
|
|
75
|
+
try {
|
|
76
|
+
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
77
|
+
const config = JSON.parse(configContent);
|
|
78
|
+
// 如果端口是旧的默认值 3001,自动更新为 3021
|
|
79
|
+
if (config.server && config.server.port === DEV_PORT) {
|
|
80
|
+
console.log(`[CLI] 检测到旧端口配置 (${DEV_PORT}),正在迁移到生产环境端口 (${PRODUCTION_PORT})...`);
|
|
81
|
+
config.server.port = PRODUCTION_PORT;
|
|
82
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
83
|
+
console.log(`[CLI] ✅ 端口已更新为 ${PRODUCTION_PORT}`);
|
|
84
|
+
}
|
|
85
|
+
} catch (e) {
|
|
86
|
+
console.warn('[CLI] 检查现有配置失败:', e);
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 如果配置文件不存在,创建新的默认配置
|
|
92
|
+
const templatePath = path.join(packageRoot, 'config.json');
|
|
93
|
+
try {
|
|
94
|
+
if (fs.existsSync(templatePath)) {
|
|
95
|
+
// 复制模板,但强制修改端口为 3021
|
|
96
|
+
const templateContent = fs.readFileSync(templatePath, 'utf-8');
|
|
97
|
+
const templateConfig = JSON.parse(templateContent);
|
|
98
|
+
|
|
99
|
+
if (!templateConfig.server) templateConfig.server = {};
|
|
100
|
+
templateConfig.server.port = PRODUCTION_PORT;
|
|
101
|
+
|
|
102
|
+
fs.writeFileSync(configPath, JSON.stringify(templateConfig, null, 2));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
} catch (e) {
|
|
106
|
+
// ignore
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const fallback = {
|
|
110
|
+
server: { port: PRODUCTION_PORT, host: 'localhost' },
|
|
111
|
+
llm: { apiKey: '', baseURL: '', model: '', temperature: 0.7 },
|
|
112
|
+
logging: { level: 'info' },
|
|
113
|
+
channels: {},
|
|
114
|
+
systemPrompt: ''
|
|
115
|
+
};
|
|
116
|
+
try {
|
|
117
|
+
fs.writeFileSync(configPath, JSON.stringify(fallback, null, 2));
|
|
118
|
+
} catch (e) {
|
|
119
|
+
console.error(`[CLI] 无法写入默认配置:${configPath}`);
|
|
120
|
+
console.error(e);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function ensureDefaultDirs() {
|
|
126
|
+
for (const dir of ['logs', 'data', 'memories', 'sessions', 'workspace']) {
|
|
127
|
+
try {
|
|
128
|
+
fs.mkdirSync(path.join(APP_HOME, dir), { recursive: true });
|
|
129
|
+
} catch {
|
|
130
|
+
// ignore
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getPortFromConfig() {
|
|
136
|
+
let port = PRODUCTION_PORT;
|
|
137
|
+
const configPath = path.join(APP_HOME, 'config.json');
|
|
138
|
+
if (fs.existsSync(configPath)) {
|
|
139
|
+
try {
|
|
140
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
141
|
+
if (config.server && config.server.port) {
|
|
142
|
+
port = config.server.port;
|
|
143
|
+
}
|
|
144
|
+
} catch (e) {
|
|
145
|
+
// ignore
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return port;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getPidFilePath() {
|
|
152
|
+
return path.join(APP_HOME, 'logs', 'server.pid');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function hasFlag(flag) {
|
|
156
|
+
return commandArgs.includes(flag);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getFlagValue(name) {
|
|
160
|
+
const idx = commandArgs.findIndex(a => a === name);
|
|
161
|
+
if (idx < 0) return null;
|
|
162
|
+
const next = commandArgs[idx + 1];
|
|
163
|
+
if (!next || next.startsWith('-')) return null;
|
|
164
|
+
return next;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function getRegistry() {
|
|
168
|
+
if (hasFlag('-cn')) {
|
|
169
|
+
return 'https://registry.npmmirror.com';
|
|
170
|
+
} else if (hasFlag('-en')) {
|
|
171
|
+
return 'https://registry.npmjs.org';
|
|
172
|
+
}
|
|
173
|
+
return getFlagValue('--registry') || process.env.npm_config_registry || 'https://registry.npmjs.org';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isProcessRunning(pid) {
|
|
177
|
+
try {
|
|
178
|
+
process.kill(pid, 0);
|
|
179
|
+
return true;
|
|
180
|
+
} catch (e) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function isPortOpen(port) {
|
|
186
|
+
return await new Promise((resolve) => {
|
|
187
|
+
const socket = net.connect({ host: '127.0.0.1', port }, () => {
|
|
188
|
+
socket.destroy();
|
|
189
|
+
resolve(true);
|
|
190
|
+
});
|
|
191
|
+
socket.on('error', () => {
|
|
192
|
+
resolve(false);
|
|
193
|
+
});
|
|
194
|
+
socket.setTimeout(500, () => {
|
|
195
|
+
socket.destroy();
|
|
196
|
+
resolve(false);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function killProcessTree(pid) {
|
|
202
|
+
if (process.platform === 'win32') {
|
|
203
|
+
try {
|
|
204
|
+
await runCommand('taskkill', ['/pid', pid.toString(), '/T', '/F'], { stdio: 'ignore' });
|
|
205
|
+
return true;
|
|
206
|
+
} catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Unix-like implementation
|
|
212
|
+
const tryKill = (targetPid, signal) => {
|
|
213
|
+
try {
|
|
214
|
+
process.kill(targetPid, signal);
|
|
215
|
+
return true;
|
|
216
|
+
} catch (e) {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
tryKill(-pid, 'SIGTERM');
|
|
222
|
+
tryKill(pid, 'SIGTERM');
|
|
223
|
+
|
|
224
|
+
// Wait a bit
|
|
225
|
+
await new Promise(r => setTimeout(r, 500));
|
|
226
|
+
|
|
227
|
+
if (isProcessRunning(pid)) {
|
|
228
|
+
tryKill(-pid, 'SIGKILL');
|
|
229
|
+
tryKill(pid, 'SIGKILL');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return !isProcessRunning(pid);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function stopServer() {
|
|
236
|
+
const pidFile = getPidFilePath();
|
|
237
|
+
const port = getPortFromConfig();
|
|
238
|
+
|
|
239
|
+
const killByPort = async () => {
|
|
240
|
+
if (process.platform === 'win32') {
|
|
241
|
+
// Windows implementation using netstat
|
|
242
|
+
try {
|
|
243
|
+
const netstat = spawn('netstat', ['-ano'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
244
|
+
let output = '';
|
|
245
|
+
netstat.stdout.on('data', d => output += d.toString());
|
|
246
|
+
await new Promise(r => netstat.on('close', r));
|
|
247
|
+
|
|
248
|
+
const lines = output.split('\n');
|
|
249
|
+
const pids = new Set();
|
|
250
|
+
for (const line of lines) {
|
|
251
|
+
if (line.includes(`:${port}`)) {
|
|
252
|
+
const parts = line.trim().split(/\s+/);
|
|
253
|
+
const pid = parts[parts.length - 1];
|
|
254
|
+
if (pid && !isNaN(Number(pid)) && Number(pid) > 0) {
|
|
255
|
+
pids.add(Number(pid));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (pids.size === 0) return false;
|
|
261
|
+
|
|
262
|
+
for (const pid of pids) {
|
|
263
|
+
await killProcessTree(pid);
|
|
264
|
+
}
|
|
265
|
+
return true;
|
|
266
|
+
} catch (e) {
|
|
267
|
+
console.error('[CLI] Windows port kill failed:', e);
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
// Unix implementation using lsof
|
|
272
|
+
const lsof = spawn('lsof', ['-ti', `tcp:${port}`], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
273
|
+
let output = '';
|
|
274
|
+
lsof.stdout.on('data', (d) => { output += d.toString(); });
|
|
275
|
+
await new Promise((resolve) => lsof.on('close', resolve));
|
|
276
|
+
|
|
277
|
+
const pids = output
|
|
278
|
+
.split(/\s+/)
|
|
279
|
+
.map(s => s.trim())
|
|
280
|
+
.filter(Boolean)
|
|
281
|
+
.map(Number)
|
|
282
|
+
.filter(n => Number.isFinite(n) && n > 0);
|
|
283
|
+
|
|
284
|
+
if (pids.length === 0) return false;
|
|
285
|
+
|
|
286
|
+
for (const pid of pids) {
|
|
287
|
+
await killProcessTree(pid);
|
|
288
|
+
}
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
if (fs.existsSync(pidFile)) {
|
|
294
|
+
const pidStr = fs.readFileSync(pidFile, 'utf-8').trim();
|
|
295
|
+
const pid = Number(pidStr);
|
|
296
|
+
|
|
297
|
+
// ... existing pid checks ...
|
|
298
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
299
|
+
fs.unlinkSync(pidFile);
|
|
300
|
+
console.log('[CLI] 未找到可用的 PID(已清理 pid 文件)。');
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!isProcessRunning(pid)) {
|
|
305
|
+
fs.unlinkSync(pidFile);
|
|
306
|
+
console.log('[CLI] 服务未在运行(已清理 pid 文件)。');
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
console.log(`[CLI] 正在停止服务(PID: ${pid})...`);
|
|
311
|
+
await killProcessTree(pid);
|
|
312
|
+
await killByPort();
|
|
313
|
+
try { fs.unlinkSync(pidFile); } catch (e) {}
|
|
314
|
+
|
|
315
|
+
if (!(await isPortOpen(port))) {
|
|
316
|
+
console.log('[CLI] ✅ 服务已停止。');
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Fallback message
|
|
321
|
+
console.error('[CLI] ❌ 停止失败:端口仍在占用。');
|
|
322
|
+
if (process.platform !== 'win32') {
|
|
323
|
+
console.error(`[CLI] 你可以手动执行:lsof -ti tcp:${port} | xargs kill -9`);
|
|
324
|
+
} else {
|
|
325
|
+
console.error(`[CLI] 你可以手动执行:netstat -ano | findstr :${port} 并 kill 对应 PID`);
|
|
326
|
+
}
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
console.log('[CLI] 未找到 pid 文件,尝试按端口停止...');
|
|
331
|
+
const ok = await killByPort();
|
|
332
|
+
if (!ok) {
|
|
333
|
+
console.log(`[CLI] 未发现占用端口 ${port} 的进程。`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (!(await isPortOpen(port))) {
|
|
337
|
+
console.log('[CLI] ✅ 服务已停止。');
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
console.error('[CLI] ❌ 停止失败:端口仍在占用。');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function runCommand(cmd, cmdArgs, options = {}) {
|
|
344
|
+
// On Windows, use npm.cmd instead of npm
|
|
345
|
+
const command = process.platform === 'win32' && cmd === 'npm' ? 'npm.cmd' : cmd;
|
|
346
|
+
// For Windows, use shell: true to ensure commands are executed correctly
|
|
347
|
+
const child = spawn(command, cmdArgs, { stdio: 'inherit', shell: process.platform === 'win32', ...options });
|
|
348
|
+
const code = await new Promise((resolve) => child.on('close', resolve));
|
|
349
|
+
return typeof code === 'number' ? code : 1;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function runWithSudoIfNeeded(cmd, cmdArgs, options = {}) {
|
|
353
|
+
const code = await runCommand(cmd, cmdArgs, options);
|
|
354
|
+
if (code === 0) return 0;
|
|
355
|
+
|
|
356
|
+
const canSudo = process.platform !== 'win32' && process.stdin.isTTY;
|
|
357
|
+
if (!canSudo) return code;
|
|
358
|
+
|
|
359
|
+
console.log('[CLI] 权限不足,尝试使用 sudo 继续...');
|
|
360
|
+
return await runCommand('sudo', [cmd, ...cmdArgs], options);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function getRemoteVersion(registry) {
|
|
364
|
+
return new Promise((resolve, reject) => {
|
|
365
|
+
const options = {
|
|
366
|
+
hostname: registry.replace('https://', ''),
|
|
367
|
+
path: '/xiaozuoassistant/latest',
|
|
368
|
+
method: 'GET',
|
|
369
|
+
headers: {
|
|
370
|
+
'Content-Type': 'application/json'
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const req = https.request(options, (res) => {
|
|
375
|
+
let data = '';
|
|
376
|
+
res.on('data', (chunk) => {
|
|
377
|
+
data += chunk;
|
|
378
|
+
});
|
|
379
|
+
res.on('end', () => {
|
|
380
|
+
try {
|
|
381
|
+
const info = JSON.parse(data);
|
|
382
|
+
resolve(info.version);
|
|
383
|
+
} catch (e) {
|
|
384
|
+
reject(new Error('Failed to parse remote version info'));
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
req.on('error', (e) => {
|
|
390
|
+
reject(e);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
req.end();
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function compareVersions(version1, version2) {
|
|
398
|
+
const v1 = version1.split('.').map(Number);
|
|
399
|
+
const v2 = version2.split('.').map(Number);
|
|
400
|
+
for (let i = 0; i < Math.max(v1.length, v2.length); i++) {
|
|
401
|
+
const num1 = v1[i] || 0;
|
|
402
|
+
const num2 = v2[i] || 0;
|
|
403
|
+
if (num1 > num2) return 1;
|
|
404
|
+
if (num1 < num2) return -1;
|
|
405
|
+
}
|
|
406
|
+
return 0;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function updateApp() {
|
|
410
|
+
const registry = getRegistry();
|
|
411
|
+
const port = getPortFromConfig();
|
|
412
|
+
const pidFile = getPidFilePath();
|
|
413
|
+
|
|
414
|
+
// Get local version
|
|
415
|
+
const packageJsonPath = path.join(__dirname, '..', 'package.json');
|
|
416
|
+
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf-8');
|
|
417
|
+
const packageJson = JSON.parse(packageJsonContent);
|
|
418
|
+
const localVersion = packageJson.version;
|
|
419
|
+
console.log(`[CLI] 当前版本: ${localVersion}`);
|
|
420
|
+
|
|
421
|
+
// Get remote version
|
|
422
|
+
try {
|
|
423
|
+
console.log(`[CLI] 检查远程版本(registry=${registry})...`);
|
|
424
|
+
const remoteVersion = await getRemoteVersion(registry);
|
|
425
|
+
console.log(`[CLI] 远程版本: ${remoteVersion}`);
|
|
426
|
+
|
|
427
|
+
// Compare versions
|
|
428
|
+
const comparison = compareVersions(localVersion, remoteVersion);
|
|
429
|
+
if (comparison >= 0) {
|
|
430
|
+
console.log('[CLI] ✅ 已经是最新版本,无需更新。');
|
|
431
|
+
process.exit(0);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
console.log(`[CLI] 发现新版本 ${remoteVersion},开始更新...`);
|
|
435
|
+
} catch (e) {
|
|
436
|
+
console.warn('[CLI] ⚠️ 版本检查失败,继续执行更新:', e);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const runningByPid = fs.existsSync(pidFile) && (() => {
|
|
440
|
+
try {
|
|
441
|
+
const pid = Number(fs.readFileSync(pidFile, 'utf-8').trim());
|
|
442
|
+
return Number.isFinite(pid) && pid > 0 && isProcessRunning(pid);
|
|
443
|
+
} catch {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
})();
|
|
447
|
+
const runningByPort = await isPortOpen(port);
|
|
448
|
+
const wasRunning = runningByPid || runningByPort;
|
|
449
|
+
|
|
450
|
+
if (wasRunning) {
|
|
451
|
+
console.log('[CLI] 检测到服务正在运行,更新前将自动停止...');
|
|
452
|
+
await stopServer();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Backup user config before update
|
|
456
|
+
const configPath = path.join(APP_HOME, 'config.json');
|
|
457
|
+
const configBackupPath = path.join(APP_HOME, 'config.json.bak');
|
|
458
|
+
let configBackedUp = false;
|
|
459
|
+
if (fs.existsSync(configPath)) {
|
|
460
|
+
try {
|
|
461
|
+
fs.copyFileSync(configPath, configBackupPath);
|
|
462
|
+
configBackedUp = true;
|
|
463
|
+
console.log('[CLI] 用户配置已备份。');
|
|
464
|
+
} catch (e) {
|
|
465
|
+
console.warn('[CLI] ⚠️ 用户配置备份失败:', e);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
console.log(`[CLI] 正在更新 xiaozuoassistant(registry=${registry})...`);
|
|
470
|
+
const code = await runWithSudoIfNeeded('npm', ['install', '-g', 'xiaozuoassistant@latest', `--registry=${registry}`]);
|
|
471
|
+
|
|
472
|
+
// Restore user config after update
|
|
473
|
+
if (configBackedUp && fs.existsSync(configBackupPath)) {
|
|
474
|
+
try {
|
|
475
|
+
fs.copyFileSync(configBackupPath, configPath);
|
|
476
|
+
fs.unlinkSync(configBackupPath);
|
|
477
|
+
console.log('[CLI] 用户配置已恢复。');
|
|
478
|
+
} catch (e) {
|
|
479
|
+
console.warn('[CLI] ⚠️ 用户配置恢复失败:', e);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (code !== 0) {
|
|
484
|
+
console.error('[CLI] ❌ 更新失败。');
|
|
485
|
+
if (wasRunning) {
|
|
486
|
+
console.log('[CLI] 更新失败,尝试恢复启动旧版本服务...');
|
|
487
|
+
await runCommand('node', [path.join(packageRoot, 'bin', 'cli.js'), 'start'], { cwd: APP_HOME });
|
|
488
|
+
}
|
|
489
|
+
process.exit(code);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
console.log('[CLI] ✅ 更新完成。');
|
|
493
|
+
|
|
494
|
+
if (wasRunning) {
|
|
495
|
+
console.log('[CLI] 正在自动重启服务...');
|
|
496
|
+
const restartCode = await runCommand('xiaozuoAssistant', ['start'], { cwd: APP_HOME });
|
|
497
|
+
process.exit(restartCode);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function removeApp() {
|
|
502
|
+
const registry = getRegistry();
|
|
503
|
+
const targets = ['config.json', 'memories', 'data', 'logs', 'sessions', 'workspace']
|
|
504
|
+
.map(p => path.join(APP_HOME, p));
|
|
505
|
+
|
|
506
|
+
const doRemove = async () => {
|
|
507
|
+
await stopServer();
|
|
508
|
+
|
|
509
|
+
console.log('[CLI] 正在删除本地数据(当前目录)...');
|
|
510
|
+
for (const target of targets) {
|
|
511
|
+
try {
|
|
512
|
+
if (fs.existsSync(target)) {
|
|
513
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
514
|
+
console.log(`[CLI] 已删除:${target}`);
|
|
515
|
+
}
|
|
516
|
+
} catch (e) {
|
|
517
|
+
console.error(`[CLI] 删除失败:${target}`);
|
|
518
|
+
console.error(e);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
console.log('[CLI] 正在卸载全局包 xiaozuoassistant...');
|
|
523
|
+
const code = await runWithSudoIfNeeded('npm', ['uninstall', '-g', 'xiaozuoassistant', `--registry=${registry}`]);
|
|
524
|
+
if (code !== 0) {
|
|
525
|
+
console.error('[CLI] ❌ 卸载失败。');
|
|
526
|
+
process.exit(code);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
console.log('[CLI] ✅ 已卸载并清理数据。');
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
if (hasFlag('--yes') || hasFlag('-y')) {
|
|
533
|
+
await doRemove();
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
console.log('[CLI] remove 将执行以下操作:');
|
|
538
|
+
console.log(' 1) 停止后台服务');
|
|
539
|
+
console.log(' 2) 删除当前目录下的数据:');
|
|
540
|
+
for (const target of targets) console.log(` - ${target}`);
|
|
541
|
+
console.log(' 3) 卸载全局 npm 包:xiaozuoassistant');
|
|
542
|
+
process.stdout.write('确认继续?(y/N) ');
|
|
543
|
+
process.stdin.once('data', async (data) => {
|
|
544
|
+
const answer = data.toString().trim().toLowerCase();
|
|
545
|
+
if (answer === 'y' || answer === 'yes') {
|
|
546
|
+
await doRemove();
|
|
547
|
+
} else {
|
|
548
|
+
console.log('已取消。');
|
|
549
|
+
}
|
|
550
|
+
process.exit(0);
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function printVersion() {
|
|
555
|
+
try {
|
|
556
|
+
const pkgPath = path.join(packageRoot, 'package.json');
|
|
557
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
558
|
+
console.log(`${pkg.name}@${pkg.version}`);
|
|
559
|
+
console.log('Package Root:', packageRoot);
|
|
560
|
+
console.log('Node Version:', process.version);
|
|
561
|
+
} catch {
|
|
562
|
+
console.log('xiaozuoassistant@unknown');
|
|
563
|
+
console.log('Package Root:', packageRoot);
|
|
564
|
+
console.log('Node Version:', process.version);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function exportData() {
|
|
569
|
+
ensureAppHome();
|
|
570
|
+
ensureDefaultDirs();
|
|
571
|
+
console.log('📦 Starting data export...');
|
|
572
|
+
|
|
573
|
+
// Generate filename with timestamp
|
|
574
|
+
const now = new Date();
|
|
575
|
+
const timestamp = now.toISOString().replace(/[-:T.]/g, '').slice(0, 14); // YYYYMMDDHHmmss
|
|
576
|
+
const exportFilename = `${EXPORT_FILENAME_PREFIX}-${timestamp}.tar.gz`;
|
|
577
|
+
|
|
578
|
+
const filesToArchive = [];
|
|
579
|
+
|
|
580
|
+
for (const p of DATA_PATHS) {
|
|
581
|
+
if (fs.existsSync(path.join(APP_HOME, p))) {
|
|
582
|
+
filesToArchive.push(p);
|
|
583
|
+
console.log(` - Found: ${p}`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (filesToArchive.length === 0) {
|
|
588
|
+
console.warn('⚠️ No data files found to export in current directory.');
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const exportPath = path.join(APP_HOME, exportFilename);
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
await tar.c(
|
|
596
|
+
{
|
|
597
|
+
gzip: true,
|
|
598
|
+
file: exportPath,
|
|
599
|
+
cwd: APP_HOME
|
|
600
|
+
},
|
|
601
|
+
filesToArchive
|
|
602
|
+
);
|
|
603
|
+
console.log(`✅ Export successful!`);
|
|
604
|
+
console.log(` Backup file: ${exportPath}`);
|
|
605
|
+
console.log(` Copy this file to your new machine to import.`);
|
|
606
|
+
} catch (err) {
|
|
607
|
+
console.error('❌ Export failed:', err);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function importData() {
|
|
612
|
+
ensureAppHome();
|
|
613
|
+
|
|
614
|
+
// Find backup file
|
|
615
|
+
let backupFile = getFlagValue('--file');
|
|
616
|
+
|
|
617
|
+
if (!backupFile) {
|
|
618
|
+
// Try to find the latest backup file in APP_HOME
|
|
619
|
+
try {
|
|
620
|
+
const files = fs.readdirSync(APP_HOME)
|
|
621
|
+
.filter(f => f.startsWith(EXPORT_FILENAME_PREFIX) && f.endsWith('.tar.gz'))
|
|
622
|
+
.sort()
|
|
623
|
+
.reverse();
|
|
624
|
+
|
|
625
|
+
if (files.length > 0) {
|
|
626
|
+
backupFile = path.join(APP_HOME, files[0]);
|
|
627
|
+
console.log(`[CLI] No file specified, using latest found: ${files[0]}`);
|
|
628
|
+
} else {
|
|
629
|
+
// Fallback to old filename for compatibility
|
|
630
|
+
const oldFile = path.join(APP_HOME, 'xiaozuoAssistant-backup.tar.gz');
|
|
631
|
+
if (fs.existsSync(oldFile)) {
|
|
632
|
+
backupFile = oldFile;
|
|
633
|
+
console.log(`[CLI] Using legacy backup file: xiaozuoAssistant-backup.tar.gz`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
} catch (e) {
|
|
637
|
+
// ignore
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (!backupFile) {
|
|
642
|
+
console.error(`❌ No backup file found or specified.`);
|
|
643
|
+
console.log(` Use --file <path> to specify a backup file.`);
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Resolve absolute path
|
|
648
|
+
const backupPath = path.resolve(process.cwd(), backupFile);
|
|
649
|
+
|
|
650
|
+
if (!fs.existsSync(backupPath)) {
|
|
651
|
+
console.error(`❌ Backup file not found: ${backupPath}`);
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
console.log(`📦 Starting data import from: ${backupPath}`);
|
|
656
|
+
console.log('⚠️ Warning: This will overwrite existing data files (config.json, memories, etc.)');
|
|
657
|
+
|
|
658
|
+
// Simple prompt implementation for Node.js
|
|
659
|
+
process.stdout.write(' Are you sure? (y/N) ');
|
|
660
|
+
process.stdin.once('data', async (data) => {
|
|
661
|
+
const answer = data.toString().trim().toLowerCase();
|
|
662
|
+
if (answer === 'y' || answer === 'yes') {
|
|
663
|
+
try {
|
|
664
|
+
// Stop server if running before import
|
|
665
|
+
const pidFile = getPidFilePath();
|
|
666
|
+
if (fs.existsSync(pidFile)) {
|
|
667
|
+
console.log('[CLI] Stopping server before import...');
|
|
668
|
+
await stopServer();
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
await tar.x({
|
|
672
|
+
file: backupPath,
|
|
673
|
+
cwd: APP_HOME
|
|
674
|
+
});
|
|
675
|
+
console.log('✅ Import successful! Data restored.');
|
|
676
|
+
|
|
677
|
+
// Auto-configure workspace path in config.json
|
|
678
|
+
const configPath = path.join(APP_HOME, 'config.json');
|
|
679
|
+
if (fs.existsSync(configPath)) {
|
|
680
|
+
try {
|
|
681
|
+
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
682
|
+
const config = JSON.parse(configContent);
|
|
683
|
+
|
|
684
|
+
// Update workspace to current directory
|
|
685
|
+
const oldWorkspace = config.workspace;
|
|
686
|
+
config.workspace = APP_HOME;
|
|
687
|
+
|
|
688
|
+
// Also update System Prompt if it contains the old workspace path
|
|
689
|
+
if (config.systemPrompt && typeof config.systemPrompt === 'string') {
|
|
690
|
+
if (oldWorkspace && config.systemPrompt.includes(oldWorkspace)) {
|
|
691
|
+
config.systemPrompt = config.systemPrompt.replace(oldWorkspace, APP_HOME);
|
|
692
|
+
} else if (config.systemPrompt.includes('Current Workspace:')) {
|
|
693
|
+
// Fallback regex replacement if exact string match fails
|
|
694
|
+
config.systemPrompt = config.systemPrompt.replace(/Current Workspace: .*/, `Current Workspace: ${APP_HOME}`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Ensure port is set to production port if it was dev port
|
|
699
|
+
if (config.server && config.server.port === DEV_PORT) {
|
|
700
|
+
config.server.port = PRODUCTION_PORT;
|
|
701
|
+
console.log(`[CLI] Updated port from ${DEV_PORT} to ${PRODUCTION_PORT}`);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
705
|
+
console.log(`✅ Auto-configured workspace path to: ${APP_HOME}`);
|
|
706
|
+
} catch (e) {
|
|
707
|
+
console.warn('⚠️ Failed to auto-update config.json path:', e);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
console.log(' You can now start the application.');
|
|
712
|
+
} catch (err) {
|
|
713
|
+
console.error('❌ Import failed:', err);
|
|
714
|
+
}
|
|
715
|
+
} else {
|
|
716
|
+
console.log(' Import cancelled.');
|
|
717
|
+
}
|
|
718
|
+
process.exit(0);
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (command === 'start') {
|
|
723
|
+
ensureAppHome();
|
|
724
|
+
ensureDefaultDirs();
|
|
725
|
+
ensureDefaultConfig();
|
|
726
|
+
console.log('Starting xiaozuoAssistant...');
|
|
727
|
+
|
|
728
|
+
const serverPath = path.join(packageRoot, 'dist', 'server', 'index.js');
|
|
729
|
+
|
|
730
|
+
// Ensure logs directory exists in APP_HOME
|
|
731
|
+
const logDir = path.join(APP_HOME, 'logs');
|
|
732
|
+
if (!fs.existsSync(logDir)) {
|
|
733
|
+
try {
|
|
734
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
735
|
+
} catch (e) {
|
|
736
|
+
console.error(`[CLI] Failed to create logs directory at ${logDir}:`, e);
|
|
737
|
+
process.exit(1);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const outLog = path.join(logDir, 'stdout.log');
|
|
742
|
+
const errLog = path.join(logDir, 'stderr.log');
|
|
743
|
+
|
|
744
|
+
console.log(`[CLI] Launching server from: ${serverPath}`);
|
|
745
|
+
console.log(`[CLI] Logs redirected to:`);
|
|
746
|
+
console.log(` - Stdout: ${outLog}`);
|
|
747
|
+
console.log(` - Stderr: ${errLog}`);
|
|
748
|
+
console.log(` - App Logs (Rotated): ${path.join(logDir, 'app.log')}`);
|
|
749
|
+
|
|
750
|
+
const port = getPortFromConfig();
|
|
751
|
+
const pidFile = getPidFilePath();
|
|
752
|
+
|
|
753
|
+
if (await isPortOpen(port)) {
|
|
754
|
+
console.error(`[CLI] 端口 ${port} 已被占用,可能服务已在运行。`);
|
|
755
|
+
console.error('[CLI] 可先执行:xiaozuoAssistant stop');
|
|
756
|
+
process.exit(1);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
if (fs.existsSync(pidFile)) {
|
|
760
|
+
try {
|
|
761
|
+
const pid = Number(fs.readFileSync(pidFile, 'utf-8').trim());
|
|
762
|
+
if (Number.isFinite(pid) && pid > 0 && isProcessRunning(pid)) {
|
|
763
|
+
console.log(`[CLI] 服务已在运行(PID: ${pid})。`);
|
|
764
|
+
console.log(`[CLI] 访问地址: http://localhost:${port}`);
|
|
765
|
+
process.exit(0);
|
|
766
|
+
}
|
|
767
|
+
fs.unlinkSync(pidFile);
|
|
768
|
+
} catch (e) {
|
|
769
|
+
// ignore
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
console.log(`[CLI] Waiting for server to start on port ${port}...`);
|
|
774
|
+
|
|
775
|
+
try {
|
|
776
|
+
const out = fs.openSync(outLog, 'a');
|
|
777
|
+
const err = fs.openSync(errLog, 'a');
|
|
778
|
+
|
|
779
|
+
const child = spawn(process.execPath, [serverPath, ...args.slice(1)], {
|
|
780
|
+
detached: true, // Allow child to run independently
|
|
781
|
+
stdio: ['ignore', out, err], // Disconnect stdin, redirect stdout/stderr
|
|
782
|
+
cwd: APP_HOME, // Run in unified app home
|
|
783
|
+
env: {
|
|
784
|
+
...process.env,
|
|
785
|
+
NODE_ENV: 'production'
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
try {
|
|
790
|
+
fs.writeFileSync(pidFile, String(child.pid));
|
|
791
|
+
} catch (e) {
|
|
792
|
+
// ignore
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
let childExited = false;
|
|
796
|
+
child.on('exit', (code) => {
|
|
797
|
+
childExited = true;
|
|
798
|
+
console.error(`\n[CLI] Server process exited unexpectedly with code ${code}.`);
|
|
799
|
+
console.error(`[CLI] Check logs at ${errLog} or ${outLog}`);
|
|
800
|
+
process.exit(1);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
// Health check function
|
|
804
|
+
const checkHealth = async (port, retries = 30) => {
|
|
805
|
+
const check = () => new Promise((resolve, reject) => {
|
|
806
|
+
const req = http.get(`http://localhost:${port}/api/health`, (res) => {
|
|
807
|
+
if (res.statusCode === 200) {
|
|
808
|
+
resolve(true);
|
|
809
|
+
} else {
|
|
810
|
+
reject(new Error(`Status ${res.statusCode}`));
|
|
811
|
+
}
|
|
812
|
+
res.resume();
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
req.on('error', (err) => reject(err));
|
|
816
|
+
req.setTimeout(500, () => {
|
|
817
|
+
req.destroy();
|
|
818
|
+
reject(new Error('Timeout'));
|
|
819
|
+
});
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
for (let i = 0; i < retries; i++) {
|
|
823
|
+
if (childExited) throw new Error('Child process exited');
|
|
824
|
+
try {
|
|
825
|
+
await check();
|
|
826
|
+
return true;
|
|
827
|
+
} catch (e) {
|
|
828
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
829
|
+
process.stdout.write('.');
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
throw new Error('Health check timed out');
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
// Perform health check
|
|
836
|
+
await checkHealth(port);
|
|
837
|
+
console.log(`\n[CLI] ✅ Health check passed!`);
|
|
838
|
+
|
|
839
|
+
// Unreference the child so the parent process can exit
|
|
840
|
+
child.unref();
|
|
841
|
+
|
|
842
|
+
console.log(`[CLI] Server running in background.`);
|
|
843
|
+
console.log(`[CLI] Access the Web UI at: http://localhost:${port}`);
|
|
844
|
+
process.exit(0);
|
|
845
|
+
} catch (error) {
|
|
846
|
+
try { if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile); } catch (e) {}
|
|
847
|
+
console.error(`\n[CLI] Failed to start server: ${error.message}`);
|
|
848
|
+
process.exit(1);
|
|
849
|
+
}
|
|
850
|
+
} else if (command === 'doctor') {
|
|
851
|
+
ensureAppHome();
|
|
852
|
+
ensureDefaultDirs();
|
|
853
|
+
console.log('Running doctor check...');
|
|
854
|
+
try {
|
|
855
|
+
const pkgPath = path.join(packageRoot, 'package.json');
|
|
856
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
857
|
+
console.log('Package Name:', pkg.name);
|
|
858
|
+
console.log('Package Version:', pkg.version);
|
|
859
|
+
} catch (e) {
|
|
860
|
+
console.log('Package Version: Unknown');
|
|
861
|
+
}
|
|
862
|
+
console.log('Package Root:', packageRoot);
|
|
863
|
+
console.log('Node Version:', process.version);
|
|
864
|
+
console.log('Doctor check complete.');
|
|
865
|
+
} else if (command === 'version') {
|
|
866
|
+
ensureAppHome();
|
|
867
|
+
printVersion();
|
|
868
|
+
} else if (command === 'update') {
|
|
869
|
+
updateApp();
|
|
870
|
+
} else if (command === 'remove') {
|
|
871
|
+
removeApp();
|
|
872
|
+
} else if (command === 'export') {
|
|
873
|
+
exportData();
|
|
874
|
+
} else if (command === 'import') {
|
|
875
|
+
importData();
|
|
876
|
+
} else if (command === 'stop') {
|
|
877
|
+
ensureAppHome();
|
|
878
|
+
stopServer();
|
|
879
|
+
} else {
|
|
880
|
+
console.log(`Usage: ${binName} <command>`);
|
|
881
|
+
console.log('Commands:');
|
|
882
|
+
console.log(' start Start the xiaozuoAssistant server');
|
|
883
|
+
console.log(' stop Stop the xiaozuoAssistant server');
|
|
884
|
+
console.log(' doctor Check the health and configuration');
|
|
885
|
+
console.log(' version Print package version and environment');
|
|
886
|
+
console.log(' update Update xiaozuoassistant and auto-restart if running');
|
|
887
|
+
console.log(' Options:');
|
|
888
|
+
console.log(' -cn Use Chinese npm registry (https://registry.npmmirror.com)');
|
|
889
|
+
console.log(' -en Use English npm registry (https://registry.npmjs.org)');
|
|
890
|
+
console.log(' remove Uninstall and delete data in current directory');
|
|
891
|
+
console.log(' export Backup local data (config, memories) to a file');
|
|
892
|
+
console.log(' import Restore data from a backup file');
|
|
893
|
+
console.log('');
|
|
894
|
+
console.log(`Data Dir: ${APP_HOME}`);
|
|
895
|
+
console.log(' Use --home <path> or env XIAOZUOASSISTANT_HOME to override.');
|
|
896
|
+
}
|