xiaozuoassistant 0.1.91 → 0.1.93

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