@sciagent/cli 1.1.10 → 1.1.12

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/sciagent.js CHANGED
@@ -21,9 +21,11 @@ const { spawn, execSync } = require('child_process');
21
21
  const path = require('path');
22
22
  const fs = require('fs');
23
23
  const os = require('os');
24
+ const https = require('https');
25
+ const http = require('http');
24
26
 
25
27
  // 当前版本号 - 与 postinstall.js 和 package.json 保持同步
26
- const CURRENT_VERSION = '1.1.10';
28
+ const CURRENT_VERSION = '1.1.12';
27
29
 
28
30
  // 极狐GitLab下载配置
29
31
  const JIHULAB_URL = 'https://jihulab.com';
@@ -60,6 +62,285 @@ function compareVersions(a, b) {
60
62
  return 0;
61
63
  }
62
64
 
65
+ /**
66
+ * 检查并应用 .pending-update 标记的更新
67
+ * 在 sciagent 启动时调用(此时旧进程已退出,二进制文件不再被锁定)
68
+ * 返回: true 如果应用了更新,false 如果没有待更新
69
+ */
70
+ function applyPendingUpdate(installDir) {
71
+ const pendingFile = path.join(installDir, '.pending-update');
72
+
73
+ if (!fs.existsSync(pendingFile)) {
74
+ return false;
75
+ }
76
+
77
+ try {
78
+ const pendingData = JSON.parse(fs.readFileSync(pendingFile, 'utf8'));
79
+ const { version, tempPath, timestamp } = pendingData;
80
+
81
+ console.log(`[UPDATE] Found pending update: v${version} (queued at ${timestamp})`);
82
+
83
+ // 检查临时文件是否存在
84
+ if (!fs.existsSync(tempPath)) {
85
+ console.log(`[UPDATE] Temp file not found: ${tempPath}`);
86
+ console.log(`[UPDATE] Clearing pending update marker.`);
87
+ fs.unlinkSync(pendingFile);
88
+ return false;
89
+ }
90
+
91
+ // 验证临时文件大小
92
+ const tempStats = fs.statSync(tempPath);
93
+ if (tempStats.size < 10 * 1024 * 1024) {
94
+ console.log(`[UPDATE] Temp file too small (${(tempStats.size / 1024).toFixed(0)} KB), discarding.`);
95
+ try { fs.unlinkSync(tempPath); } catch (e) {}
96
+ fs.unlinkSync(pendingFile);
97
+ return false;
98
+ }
99
+
100
+ // 确定目标路径
101
+ const platform = PLATFORM_MAP[process.platform];
102
+ const binName = platform === 'win32' ? 'sciagent.exe' : 'sciagent';
103
+ const targetPath = path.join(installDir, binName);
104
+
105
+ // 尝试替换
106
+ try {
107
+ if (process.platform === 'win32') {
108
+ // Windows: 使用 PowerShell
109
+ const tmpScript = path.join(os.tmpdir(), 'sciagent-apply-update.ps1');
110
+ const scriptContent = [
111
+ `Copy-Item -Path "${tempPath}" -Destination "${targetPath}" -Force`,
112
+ 'if (Test-Path $out) { Write-Host "OK" } else { exit 1 }'
113
+ ].join('\r\n');
114
+ fs.writeFileSync(tmpScript, scriptContent, 'utf8');
115
+ try {
116
+ execSync(`powershell -NoProfile -ExecutionPolicy Bypass -File "${tmpScript}"`, {
117
+ stdio: 'pipe', timeout: 30000
118
+ });
119
+ } finally {
120
+ try { fs.unlinkSync(tmpScript); } catch (e) {}
121
+ }
122
+ } else {
123
+ fs.copyFileSync(tempPath, targetPath);
124
+ fs.chmodSync(targetPath, 0o755);
125
+ }
126
+
127
+ // 验证替换成功
128
+ if (fs.existsSync(targetPath)) {
129
+ const targetStats = fs.statSync(targetPath);
130
+ if (targetStats.size > 10 * 1024 * 1024) {
131
+ // 更新版本文件
132
+ fs.writeFileSync(path.join(installDir, '.version'), version);
133
+ // 清理临时文件和标记
134
+ try { fs.unlinkSync(tempPath); } catch (e) {}
135
+ fs.unlinkSync(pendingFile);
136
+ console.log(`[UPDATE] ✅ Applied pending update: v${version} (${(targetStats.size / (1024 * 1024)).toFixed(1)} MB)`);
137
+ return true;
138
+ }
139
+ }
140
+
141
+ console.log(`[UPDATE] ⚠️ Replace succeeded but validation failed.`);
142
+ } catch (e) {
143
+ console.log(`[UPDATE] ⚠️ Failed to apply pending update: ${e.message}`);
144
+ console.log(`[UPDATE] The update will be retried on next startup.`);
145
+ // 不删除 pending 文件,下次启动重试
146
+ return false;
147
+ }
148
+ } catch (e) {
149
+ console.log(`[UPDATE] ⚠️ Error reading pending update: ${e.message}`);
150
+ try { fs.unlinkSync(pendingFile); } catch (e2) {}
151
+ }
152
+
153
+ return false;
154
+ }
155
+
156
+ /**
157
+ * 从极狐GitLab查询最新版本号
158
+ * 返回: 版本字符串 或 null
159
+ */
160
+ function fetchLatestVersion() {
161
+ return new Promise((resolve) => {
162
+ const url = (
163
+ `${JIHULAB_URL}/api/v4/projects/${JIHULAB_PROJECT_ID}` +
164
+ `/packages?package_name=${JIHULAB_PACKAGE_NAME}&per_page=1&order_by=version&sort=desc` +
165
+ `&access_token=${JIHULAB_DOWNLOAD_TOKEN}`
166
+ );
167
+
168
+ const protocol = url.startsWith('https') ? https : http;
169
+ const request = protocol.get(url, {
170
+ headers: { 'User-Agent': 'sciagent-cli/1.0' },
171
+ timeout: 10000
172
+ }, (response) => {
173
+ let data = '';
174
+ response.on('data', (chunk) => { data += chunk; });
175
+ response.on('end', () => {
176
+ try {
177
+ const packages = JSON.parse(data);
178
+ if (Array.isArray(packages) && packages.length > 0 && packages[0].version) {
179
+ resolve(packages[0].version);
180
+ } else {
181
+ resolve(null);
182
+ }
183
+ } catch (e) {
184
+ resolve(null);
185
+ }
186
+ });
187
+ });
188
+
189
+ request.on('error', () => resolve(null));
190
+ request.on('timeout', () => { request.destroy(); resolve(null); });
191
+ });
192
+ }
193
+
194
+ /**
195
+ * 后台下载新版本到临时目录
196
+ * 下载完成后写入 .pending-update 标记
197
+ * 这是异步操作,不阻塞主进程启动
198
+ */
199
+ function backgroundDownloadUpdate(platform, arch, latestVersion) {
200
+ const binName = platform === 'win32' ? 'sciagent.exe' : 'sciagent';
201
+ const ext = platform === 'win32' ? '.exe' : '';
202
+ const filename = `sciagent-${platform}-${arch}${ext}`;
203
+
204
+ const downloadUrl = (
205
+ `${JIHULAB_URL}/api/v4/projects/${JIHULAB_PROJECT_ID}` +
206
+ `/packages/generic/${JIHULAB_PACKAGE_NAME}/${latestVersion}/${filename}` +
207
+ `?access_token=${JIHULAB_DOWNLOAD_TOKEN}`
208
+ );
209
+
210
+ const installDir = getInstallDir(platform);
211
+ const tempDir = path.join(os.tmpdir(), 'sciagent-update');
212
+ fs.mkdirSync(tempDir, { recursive: true });
213
+ const tempPath = path.join(tempDir, binName);
214
+
215
+ // 清理旧的临时文件
216
+ try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) {}
217
+
218
+ console.log(`[AUTO-UPDATE] Downloading v${latestVersion} in background...`);
219
+
220
+ if (platform === 'win32') {
221
+ // Windows: 使用 PowerShell 后台下载
222
+ const tmpScript = path.join(os.tmpdir(), 'sciagent-bg-download.ps1');
223
+ const scriptContent = [
224
+ '$ProgressPreference = "SilentlyContinue"',
225
+ `$uri = "${downloadUrl}"`,
226
+ `$out = "${tempPath}"`,
227
+ 'Invoke-WebRequest -Uri $uri -OutFile $out -UseBasicParsing',
228
+ `if (Test-Path $out) { $s = (Get-Item $out).Length; Write-Host "Downloaded: $s bytes" } else { exit 1 }`
229
+ ].join('\r\n');
230
+ fs.writeFileSync(tmpScript, scriptContent, 'utf8');
231
+
232
+ const bgProcess = spawn('powershell', [
233
+ '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', tmpScript
234
+ ], { stdio: 'pipe', detached: true, windowsHide: true });
235
+
236
+ bgProcess.on('exit', (code) => {
237
+ try { fs.unlinkSync(tmpScript); } catch (e) {}
238
+ if (code === 0 && fs.existsSync(tempPath)) {
239
+ const stats = fs.statSync(tempPath);
240
+ if (stats.size > 10 * 1024 * 1024) {
241
+ console.log(`[AUTO-UPDATE] ✅ Downloaded v${latestVersion} (${(stats.size / (1024 * 1024)).toFixed(1)} MB)`);
242
+ // 写入 pending update 标记
243
+ const pendingData = {
244
+ version: latestVersion,
245
+ tempPath: tempPath,
246
+ timestamp: new Date().toISOString(),
247
+ platform: process.platform
248
+ };
249
+ fs.writeFileSync(path.join(installDir, '.pending-update'), JSON.stringify(pendingData, null, 2), 'utf8');
250
+ console.log(`[AUTO-UPDATE] 📋 Update will be applied on next restart.`);
251
+ }
252
+ } else {
253
+ console.log(`[AUTO-UPDATE] ⚠️ Background download failed.`);
254
+ try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) {}
255
+ }
256
+ });
257
+
258
+ bgProcess.unref();
259
+ } else {
260
+ // Linux/macOS: 使用 curl 后台下载
261
+ const bgProcess = spawn('curl', [
262
+ '-fsSL', '-o', tempPath, downloadUrl
263
+ ], { stdio: 'pipe', detached: true });
264
+
265
+ bgProcess.on('exit', (code) => {
266
+ if (code === 0 && fs.existsSync(tempPath)) {
267
+ const stats = fs.statSync(tempPath);
268
+ if (stats.size > 10 * 1024 * 1024) {
269
+ fs.chmodSync(tempPath, 0o755);
270
+ console.log(`[AUTO-UPDATE] ✅ Downloaded v${latestVersion} (${(stats.size / (1024 * 1024)).toFixed(1)} MB)`);
271
+ const pendingData = {
272
+ version: latestVersion,
273
+ tempPath: tempPath,
274
+ timestamp: new Date().toISOString(),
275
+ platform: process.platform
276
+ };
277
+ fs.writeFileSync(path.join(installDir, '.pending-update'), JSON.stringify(pendingData, null, 2), 'utf8');
278
+ console.log(`[AUTO-UPDATE] 📋 Update will be applied on next restart.`);
279
+ }
280
+ } else {
281
+ console.log(`[AUTO-UPDATE] ⚠️ Background download failed.`);
282
+ try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) {}
283
+ }
284
+ });
285
+
286
+ bgProcess.unref();
287
+ }
288
+ }
289
+
290
+ /**
291
+ * 检查自动更新(后台异步)
292
+ * 1. 查询极狐GitLab最新版本
293
+ * 2. 如果有新版本,后台下载到临时目录
294
+ * 3. 下载完成后写入 .pending-update 标记
295
+ * 4. 下次启动时自动应用
296
+ */
297
+ function checkAutoUpdate(platform, arch) {
298
+ // 不在后台检查中阻塞主进程
299
+ const installDir = getInstallDir(platform);
300
+ const pendingFile = path.join(installDir, '.pending-update');
301
+
302
+ // 如果已有待更新,跳过检查
303
+ if (fs.existsSync(pendingFile)) {
304
+ try {
305
+ const pending = JSON.parse(fs.readFileSync(pendingFile, 'utf8'));
306
+ console.log(`[AUTO-UPDATE] Pending update v${pending.version} already queued. Will apply on next restart.`);
307
+ return;
308
+ } catch (e) {}
309
+ }
310
+
311
+ // 检查上次检查时间(避免频繁查询,至少间隔4小时)
312
+ const lastCheckFile = path.join(installDir, '.last-update-check');
313
+ if (fs.existsSync(lastCheckFile)) {
314
+ try {
315
+ const lastCheck = new Date(fs.readFileSync(lastCheckFile, 'utf8').trim());
316
+ const hoursSinceLastCheck = (Date.now() - lastCheck.getTime()) / (1000 * 60 * 60);
317
+ if (hoursSinceLastCheck < 4) {
318
+ return; // 4小时内已检查过,跳过
319
+ }
320
+ } catch (e) {}
321
+ }
322
+
323
+ // 记录检查时间
324
+ fs.writeFileSync(lastCheckFile, new Date().toISOString(), 'utf8');
325
+
326
+ // 异步查询最新版本
327
+ fetchLatestVersion().then((latestVersion) => {
328
+ if (!latestVersion) {
329
+ return;
330
+ }
331
+
332
+ const cmp = compareVersions(latestVersion, CURRENT_VERSION);
333
+ if (cmp > 0) {
334
+ console.log(`[AUTO-UPDATE] 🆕 New version available: v${latestVersion} (current: v${CURRENT_VERSION})`);
335
+ console.log(`[AUTO-UPDATE] Downloading in background...`);
336
+ backgroundDownloadUpdate(platform, arch, latestVersion);
337
+ }
338
+ // 版本相同或更高,无需更新
339
+ }).catch(() => {
340
+ // 静默失败,不影响正常使用
341
+ });
342
+ }
343
+
63
344
  /**
64
345
  * 获取二进制安装目录
65
346
  */
@@ -93,7 +374,14 @@ function getBinaryPath() {
93
374
  const binPath = path.join(installDir, binName);
94
375
  const versionFile = path.join(installDir, '.version');
95
376
 
96
- // 检查已安装的二进制
377
+ // 1. 首先检查并应用 pending update(此时旧进程已退出,文件不再锁定)
378
+ const updated = applyPendingUpdate(installDir);
379
+ if (updated) {
380
+ // 更新已应用,重新读取版本信息
381
+ console.log(`[INFO] Update applied. Starting with new version...`);
382
+ }
383
+
384
+ // 2. 检查已安装的二进制
97
385
  if (fs.existsSync(binPath)) {
98
386
  const stats = fs.statSync(binPath);
99
387
  if (stats.size > 10 * 1024 * 1024) {
@@ -105,29 +393,38 @@ function getBinaryPath() {
105
393
  if (cmp === 0) {
106
394
  // 版本精确匹配 → 直接使用
107
395
  console.log(`[INFO] SciAgent v${installedVersion} (${(stats.size / (1024 * 1024)).toFixed(1)} MB, ${stats.mtime.toISOString().slice(0,10)})`);
396
+ // 后台检查自动更新
397
+ checkAutoUpdate(platform, arch);
108
398
  return binPath;
109
399
  } else if (cmp < 0) {
110
400
  // 版本过低 → 自动下载新版本
111
401
  console.log(`[INFO] SciAgent version outdated: v${installedVersion} (${(stats.size / (1024 * 1024)).toFixed(1)} MB) → v${CURRENT_VERSION}`);
112
402
  console.log(`[INFO] Downloading new version from JiHuLab...`);
113
- try { fs.unlinkSync(binPath); fs.unlinkSync(versionFile); } catch (e) {}
114
- const downloaded = downloadBinary(platform, arch, CURRENT_VERSION);
115
- if (downloaded) return downloaded;
116
- console.error(`[ERROR] Failed to download v${CURRENT_VERSION}. Please run: npm install -g @sciagent/cli`);
117
- process.exit(1);
403
+ // 注意:此时当前进程正在运行旧二进制,无法直接替换
404
+ // 下载到临时目录,写入 .pending-update,下次启动时应用
405
+ const downloaded = downloadBinaryToTemp(platform, arch, CURRENT_VERSION);
406
+ if (downloaded) {
407
+ console.log(`[INFO] ✅ New version downloaded. Please restart sciagent to apply update.`);
408
+ // 继续使用当前版本
409
+ return binPath;
410
+ }
411
+ // 下载失败,继续使用当前版本
412
+ console.log(`[INFO] ⚠️ Download failed. Continuing with current version.`);
413
+ return binPath;
118
414
  } else {
119
415
  // 版本更高 → 允许运行(用户可能手动安装了新版)
120
416
  console.log(`[INFO] SciAgent v${installedVersion} (newer than package v${CURRENT_VERSION}, ${(stats.size / (1024 * 1024)).toFixed(1)} MB)`);
417
+ checkAutoUpdate(platform, arch);
121
418
  return binPath;
122
419
  }
123
420
  } else {
124
- // 没有.version文件 → 可能是旧安装,重新下载
421
+ // 没有.version文件 → 可能是旧安装,下载到临时目录
125
422
  console.log(`[INFO] SciAgent binary found but version unknown, downloading v${CURRENT_VERSION}...`);
126
- try { fs.unlinkSync(binPath); } catch (e) {}
127
- const downloaded = downloadBinary(platform, arch, CURRENT_VERSION);
128
- if (downloaded) return downloaded;
129
- console.error(`[ERROR] Failed to download v${CURRENT_VERSION}. Please run: npm install -g @sciagent/cli`);
130
- process.exit(1);
423
+ const downloaded = downloadBinaryToTemp(platform, arch, CURRENT_VERSION);
424
+ if (downloaded) {
425
+ console.log(`[INFO] ✅ New version downloaded. Please restart sciagent to apply update.`);
426
+ }
427
+ return binPath;
131
428
  }
132
429
  } else {
133
430
  // 文件太小,损坏
@@ -151,6 +448,7 @@ function getBinaryPath() {
151
448
 
152
449
  const downloaded = downloadBinary(platform, arch, CURRENT_VERSION);
153
450
  if (downloaded && fs.existsSync(downloaded)) {
451
+ checkAutoUpdate(platform, arch);
154
452
  return downloaded;
155
453
  }
156
454
 
@@ -160,7 +458,90 @@ function getBinaryPath() {
160
458
  }
161
459
 
162
460
  /**
163
- * 从极狐GitLab下载二进制文件
461
+ * 从极狐GitLab下载二进制文件到临时目录(不替换当前运行的二进制)
462
+ * 下载完成后写入 .pending-update 标记,下次启动时自动应用
463
+ * 返回: tempPath 或 null
464
+ */
465
+ function downloadBinaryToTemp(platform, arch, version) {
466
+ const binName = platform === 'win32' ? 'sciagent.exe' : 'sciagent';
467
+ const ext = platform === 'win32' ? '.exe' : '';
468
+ const filename = `sciagent-${platform}-${arch}${ext}`;
469
+
470
+ const downloadUrl = (
471
+ `${JIHULAB_URL}/api/v4/projects/${JIHULAB_PROJECT_ID}` +
472
+ `/packages/generic/${JIHULAB_PACKAGE_NAME}/${version}/${filename}` +
473
+ `?access_token=${JIHULAB_DOWNLOAD_TOKEN}`
474
+ );
475
+
476
+ const installDir = getInstallDir(platform);
477
+ const tempDir = path.join(os.tmpdir(), 'sciagent-update');
478
+ fs.mkdirSync(tempDir, { recursive: true });
479
+ const tempPath = path.join(tempDir, binName);
480
+
481
+ // 清理旧的临时文件
482
+ try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) {}
483
+
484
+ console.log(` Downloading ${filename} v${version} to temp...`);
485
+ console.log(` Temp: ${tempPath}`);
486
+
487
+ try {
488
+ if (platform === 'win32') {
489
+ const tmpScript = path.join(os.tmpdir(), 'sciagent-download.ps1');
490
+ const scriptContent = [
491
+ '$ProgressPreference = "SilentlyContinue"',
492
+ `$uri = "${downloadUrl}"`,
493
+ `$out = "${tempPath}"`,
494
+ 'Write-Host " Downloading from JiHuLab..."',
495
+ 'Invoke-WebRequest -Uri $uri -OutFile $out -UseBasicParsing',
496
+ 'if (Test-Path $out) { $s = (Get-Item $out).Length; Write-Host " Downloaded: $s bytes" } else { Write-Error "File not created"; exit 1 }'
497
+ ].join('\r\n');
498
+ fs.writeFileSync(tmpScript, scriptContent, 'utf8');
499
+
500
+ try {
501
+ execSync(`powershell -NoProfile -ExecutionPolicy Bypass -File "${tmpScript}"`, {
502
+ stdio: 'inherit',
503
+ timeout: 600000
504
+ });
505
+ } finally {
506
+ try { fs.unlinkSync(tmpScript); } catch (e) {}
507
+ }
508
+ } else {
509
+ execSync(`curl -fsSL -o '${tempPath}' '${downloadUrl}'`, {
510
+ stdio: 'pipe',
511
+ timeout: 600000
512
+ });
513
+ fs.chmodSync(tempPath, 0o755);
514
+ }
515
+
516
+ // 验证下载
517
+ if (fs.existsSync(tempPath)) {
518
+ const stats = fs.statSync(tempPath);
519
+ if (stats.size > 10 * 1024 * 1024) {
520
+ console.log(` ✅ Downloaded: ${(stats.size / (1024 * 1024)).toFixed(1)} MB`);
521
+ // 写入 .pending-update 标记
522
+ const pendingData = {
523
+ version: version,
524
+ tempPath: tempPath,
525
+ timestamp: new Date().toISOString(),
526
+ platform: process.platform
527
+ };
528
+ fs.writeFileSync(path.join(installDir, '.pending-update'), JSON.stringify(pendingData, null, 2), 'utf8');
529
+ return tempPath;
530
+ } else {
531
+ console.error(` ❌ Downloaded file too small: ${(stats.size / 1024).toFixed(0)} KB`);
532
+ try { fs.unlinkSync(tempPath); } catch (e) {}
533
+ }
534
+ }
535
+ } catch (e) {
536
+ console.error(` ❌ Download failed: ${e.message}`);
537
+ try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e2) {}
538
+ }
539
+
540
+ return null;
541
+ }
542
+
543
+ /**
544
+ * 从极狐GitLab下载二进制文件(直接到目标路径,用于首次安装)
164
545
  */
165
546
  function downloadBinary(platform, arch, version) {
166
547
  const binName = platform === 'win32' ? 'sciagent.exe' : 'sciagent';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sciagent/cli",
3
- "version": "1.1.10",
3
+ "version": "1.1.12",
4
4
  "description": "SciAgent CLI - AI Research Assistant",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -27,7 +27,7 @@ const ARCH_MAP = {
27
27
  };
28
28
 
29
29
  // 当前版本号 - 每次发布时同步更新
30
- const CURRENT_VERSION = '1.1.10';
30
+ const CURRENT_VERSION = '1.1.12';
31
31
 
32
32
  // GitHub Releases 回退版本列表(当当前版本的 release 不存在时尝试)
33
33
  const GITHUB_FALLBACK_VERSIONS = ['1.1.3', '1.0.50', '1.0.48', '1.0.46', '1.0.40', '1.0.38', '1.0.36'];
@@ -528,6 +528,159 @@ const JIHULAB_PACKAGE_NAME = 'sciagent';
528
528
  // Deploy token with read_package_registry permission (for downloading)
529
529
  const JIHULAB_DOWNLOAD_TOKEN = '2qxfs606HfwESUYJxtRlgm86MQp1OjVnazAK.01.101vzs2qo';
530
530
 
531
+ /**
532
+ * 检查 sciagent 进程是否正在运行
533
+ */
534
+ function isSciAgentRunning() {
535
+ try {
536
+ if (process.platform === 'win32') {
537
+ const result = execSync('tasklist /FI "IMAGENAME eq sciagent.exe" /NH', {
538
+ encoding: 'utf8',
539
+ timeout: 5000
540
+ });
541
+ return result.includes('sciagent.exe');
542
+ } else {
543
+ const result = execSync('pgrep -x sciagent || true', {
544
+ encoding: 'utf8',
545
+ timeout: 5000
546
+ });
547
+ return result.trim().length > 0;
548
+ }
549
+ } catch (e) {
550
+ return false;
551
+ }
552
+ }
553
+
554
+ /**
555
+ * 终止 sciagent 进程
556
+ */
557
+ function killSciAgent() {
558
+ try {
559
+ if (process.platform === 'win32') {
560
+ execSync('taskkill /F /IM sciagent.exe', { timeout: 10000 });
561
+ } else {
562
+ execSync('pkill -x sciagent', { timeout: 10000 });
563
+ }
564
+ // 等待进程完全退出
565
+ let retries = 10;
566
+ while (retries-- > 0 && isSciAgentRunning()) {
567
+ const sleep = require('util').promisify(setTimeout);
568
+ sleep(500);
569
+ }
570
+ return !isSciAgentRunning();
571
+ } catch (e) {
572
+ return false;
573
+ }
574
+ }
575
+
576
+ /**
577
+ * 尝试将临时文件替换为目标二进制文件
578
+ * 返回: 'replaced' | 'locked' | 'error'
579
+ */
580
+ function tryReplaceBinary(tempPath, targetPath, installDir, version, platform) {
581
+ try {
582
+ // 先尝试直接复制
583
+ if (process.platform === 'win32') {
584
+ // Windows: 使用 PowerShell Copy-Item
585
+ const tmpScript = path.join(os.tmpdir(), 'sciagent-replace.ps1');
586
+ const scriptContent = [
587
+ `Copy-Item -Path "${tempPath}" -Destination "${targetPath}" -Force`,
588
+ 'if (Test-Path $out) { Write-Host "OK" } else { Write-Error "Copy failed"; exit 1 }'
589
+ ].join('\r\n');
590
+ fs.writeFileSync(tmpScript, scriptContent, 'utf8');
591
+ try {
592
+ execSync(`powershell -NoProfile -ExecutionPolicy Bypass -File "${tmpScript}"`, {
593
+ stdio: 'pipe',
594
+ timeout: 30000
595
+ });
596
+ } finally {
597
+ try { fs.unlinkSync(tmpScript); } catch (e) {}
598
+ }
599
+ } else {
600
+ fs.copyFileSync(tempPath, targetPath);
601
+ fs.chmodSync(targetPath, 0o755);
602
+ }
603
+
604
+ // 验证替换成功
605
+ if (fs.existsSync(targetPath)) {
606
+ const stats = fs.statSync(targetPath);
607
+ if (stats.size > 10 * 1024 * 1024) {
608
+ fs.writeFileSync(path.join(installDir, '.version'), version);
609
+ // 清理临时文件
610
+ try { fs.unlinkSync(tempPath); } catch (e) {}
611
+ return 'replaced';
612
+ }
613
+ }
614
+ return 'error';
615
+ } catch (e) {
616
+ // 检查是否是文件锁定错误
617
+ const msg = (e.message || '').toLowerCase();
618
+ if (msg.includes('used by another process') || msg.includes('being used') ||
619
+ msg.includes('eperm') || msg.includes('eacces') || msg.includes('access denied') ||
620
+ msg.includes('锁定') || msg.includes('denied')) {
621
+ return 'locked';
622
+ }
623
+ return 'error';
624
+ }
625
+ }
626
+
627
+ /**
628
+ * 写入 .pending-update 标记文件
629
+ * 下次 sciagent 启动时会自动应用更新
630
+ */
631
+ function writePendingUpdate(installDir, tempPath, version) {
632
+ const pendingFile = path.join(installDir, '.pending-update');
633
+ const pendingData = {
634
+ version: version,
635
+ tempPath: tempPath,
636
+ timestamp: new Date().toISOString(),
637
+ platform: process.platform
638
+ };
639
+ fs.writeFileSync(pendingFile, JSON.stringify(pendingData, null, 2), 'utf8');
640
+ console.log(` [PENDING] Update deferred. Written .pending-update marker.`);
641
+ console.log(` [PENDING] New version will be applied on next sciagent startup.`);
642
+ }
643
+
644
+ /**
645
+ * 交互式提示用户选择更新方式
646
+ * 返回: 'kill' | 'defer'
647
+ */
648
+ function promptUpdateChoice() {
649
+ // 检查是否在交互式终端中
650
+ if (!process.stdin.isTTY) {
651
+ console.log(` [INFO] Non-interactive mode. Deferring update to next startup.`);
652
+ return 'defer';
653
+ }
654
+
655
+ const readline = require('readline');
656
+ const rl = readline.createInterface({
657
+ input: process.stdin,
658
+ output: process.stdout
659
+ });
660
+
661
+ return new Promise((resolve) => {
662
+ console.log('');
663
+ console.log(' ╔══════════════════════════════════════════════════════════╗');
664
+ console.log(' ║ SciAgent is currently running. Update requires ║');
665
+ console.log(' ║ replacing the binary file which is locked. ║');
666
+ console.log(' ╠══════════════════════════════════════════════════════════╣');
667
+ console.log(' ║ [K] Kill SciAgent & update now ║');
668
+ console.log(' ║ [D] Defer - update on next startup ║');
669
+ console.log(' ╚══════════════════════════════════════════════════════════╝');
670
+ console.log('');
671
+
672
+ rl.question(' Choose [K/D] (default: D): ', (answer) => {
673
+ rl.close();
674
+ const choice = (answer || 'D').trim().toUpperCase();
675
+ if (choice === 'K' || choice === 'KILL') {
676
+ resolve('kill');
677
+ } else {
678
+ resolve('defer');
679
+ }
680
+ });
681
+ });
682
+ }
683
+
531
684
  async function downloadFromJiHuLab(platform, arch, version) {
532
685
  const binName = platform === 'win32' ? 'sciagent.exe' : 'sciagent';
533
686
  const ext = platform === 'win32' ? '.exe' : '';
@@ -546,6 +699,14 @@ async function downloadFromJiHuLab(platform, arch, version) {
546
699
  fs.mkdirSync(installDir, { recursive: true });
547
700
  const targetPath = path.join(installDir, binName);
548
701
 
702
+ // 下载到临时目录(避免直接覆盖正在运行的二进制)
703
+ const tempDir = path.join(os.tmpdir(), 'sciagent-update');
704
+ fs.mkdirSync(tempDir, { recursive: true });
705
+ const tempPath = path.join(tempDir, binName);
706
+
707
+ // 清理旧的临时文件
708
+ try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) {}
709
+
549
710
  // Windows: 优先使用 PowerShell(更可靠的大文件下载)
550
711
  if (platform === 'win32') {
551
712
  try {
@@ -553,7 +714,7 @@ async function downloadFromJiHuLab(platform, arch, version) {
553
714
  const scriptContent = [
554
715
  '$ProgressPreference = "SilentlyContinue"',
555
716
  `$uri = "${downloadUrl}"`,
556
- `$out = "${targetPath}"`,
717
+ `$out = "${tempPath}"`,
557
718
  'Write-Host " [JiHuLab] Downloading..."',
558
719
  'Invoke-WebRequest -Uri $uri -OutFile $out -UseBasicParsing',
559
720
  'if (Test-Path $out) { $s = (Get-Item $out).Length; Write-Host " [JiHuLab] Downloaded: $s bytes" } else { Write-Error "File not created"; exit 1 }'
@@ -569,13 +730,13 @@ async function downloadFromJiHuLab(platform, arch, version) {
569
730
  try { fs.unlinkSync(tmpScript); } catch (e) {}
570
731
  }
571
732
 
572
- // 验证下载
573
- if (fs.existsSync(targetPath)) {
574
- const stats = fs.statSync(targetPath);
733
+ // 验证下载到临时文件
734
+ if (fs.existsSync(tempPath)) {
735
+ const stats = fs.statSync(tempPath);
575
736
  if (stats.size > 10 * 1024 * 1024) {
576
- console.log(` [OK] JiHuLab download success: ${targetPath} (${(stats.size / (1024 * 1024)).toFixed(1)} MB)`);
577
- fs.writeFileSync(path.join(installDir, '.version'), version);
578
- return true;
737
+ console.log(` [OK] Downloaded to temp: ${tempPath} (${(stats.size / (1024 * 1024)).toFixed(1)} MB)`);
738
+ // 尝试替换
739
+ return await applyDownloadedBinary(tempPath, targetPath, installDir, version, platform);
579
740
  }
580
741
  }
581
742
 
@@ -585,21 +746,20 @@ async function downloadFromJiHuLab(platform, arch, version) {
585
746
  }
586
747
  }
587
748
 
588
- // 通用方式: Node.js https 下载
749
+ // 通用方式: Node.js https 下载到临时文件
589
750
  try {
590
- await downloadFile(downloadUrl, targetPath, 600000); // 10 分钟超时
751
+ await downloadFile(downloadUrl, tempPath, 600000); // 10 分钟超时
591
752
 
592
753
  // 验证下载
593
- if (fs.existsSync(targetPath)) {
594
- const stats = fs.statSync(targetPath);
754
+ if (fs.existsSync(tempPath)) {
755
+ const stats = fs.statSync(tempPath);
595
756
  if (stats.size > 10 * 1024 * 1024) {
596
- console.log(` [OK] JiHuLab download success: ${targetPath} (${(stats.size / (1024 * 1024)).toFixed(1)} MB)`);
757
+ console.log(` [OK] Downloaded to temp: ${tempPath} (${(stats.size / (1024 * 1024)).toFixed(1)} MB)`);
597
758
  if (platform !== 'win32') {
598
- fs.chmodSync(targetPath, 0o755);
759
+ fs.chmodSync(tempPath, 0o755);
599
760
  }
600
- // 写入版本文件
601
- fs.writeFileSync(path.join(installDir, '.version'), version);
602
- return true;
761
+ // 尝试替换
762
+ return await applyDownloadedBinary(tempPath, targetPath, installDir, version, platform);
603
763
  }
604
764
  }
605
765
 
@@ -611,6 +771,89 @@ async function downloadFromJiHuLab(platform, arch, version) {
611
771
  }
612
772
  }
613
773
 
774
+ /**
775
+ * 将已下载到临时路径的二进制文件应用到目标位置
776
+ * 处理文件锁定情况:交互式选择杀死进程或延迟更新
777
+ */
778
+ async function applyDownloadedBinary(tempPath, targetPath, installDir, version, platform) {
779
+ // 如果目标文件不存在(首次安装),直接移动
780
+ if (!fs.existsSync(targetPath)) {
781
+ try {
782
+ if (process.platform === 'win32') {
783
+ // Windows: 使用 PowerShell 移动
784
+ const tmpScript = path.join(os.tmpdir(), 'sciagent-move.ps1');
785
+ const scriptContent = `Move-Item -Path "${tempPath}" -Destination "${targetPath}" -Force`;
786
+ fs.writeFileSync(tmpScript, scriptContent, 'utf8');
787
+ try {
788
+ execSync(`powershell -NoProfile -ExecutionPolicy Bypass -File "${tmpScript}"`, {
789
+ stdio: 'pipe', timeout: 30000
790
+ });
791
+ } finally {
792
+ try { fs.unlinkSync(tmpScript); } catch (e) {}
793
+ }
794
+ } else {
795
+ fs.renameSync(tempPath, targetPath);
796
+ fs.chmodSync(targetPath, 0o755);
797
+ }
798
+ fs.writeFileSync(path.join(installDir, '.version'), version);
799
+ console.log(` [OK] Installed: ${targetPath}`);
800
+ return true;
801
+ } catch (e) {
802
+ console.log(` [WARN] Move failed: ${e.message}, trying copy...`);
803
+ // fallback to copy
804
+ const result = tryReplaceBinary(tempPath, targetPath, installDir, version, platform);
805
+ return result === 'replaced';
806
+ }
807
+ }
808
+
809
+ // 目标文件已存在,尝试替换
810
+ const replaceResult = tryReplaceBinary(tempPath, targetPath, installDir, version, platform);
811
+
812
+ if (replaceResult === 'replaced') {
813
+ console.log(` [OK] Updated: ${targetPath} (v${version})`);
814
+ return true;
815
+ }
816
+
817
+ if (replaceResult === 'locked') {
818
+ console.log(` [WARN] Binary file is locked (SciAgent is running).`);
819
+
820
+ // 交互式选择
821
+ const choice = await promptUpdateChoice();
822
+
823
+ if (choice === 'kill') {
824
+ console.log(` [INFO] Killing SciAgent process...`);
825
+ const killed = killSciAgent();
826
+ if (killed) {
827
+ console.log(` [OK] SciAgent process terminated.`);
828
+ // 重试替换
829
+ const retryResult = tryReplaceBinary(tempPath, targetPath, installDir, version, platform);
830
+ if (retryResult === 'replaced') {
831
+ console.log(` [OK] Updated: ${targetPath} (v${version})`);
832
+ return true;
833
+ } else {
834
+ console.log(` [WARN] Replace still failed after killing process. Deferring to next startup.`);
835
+ writePendingUpdate(installDir, tempPath, version);
836
+ return true; // 下载成功,只是替换延迟
837
+ }
838
+ } else {
839
+ console.log(` [WARN] Failed to kill SciAgent. Deferring update to next startup.`);
840
+ writePendingUpdate(installDir, tempPath, version);
841
+ return true; // 下载成功,只是替换延迟
842
+ }
843
+ } else {
844
+ // 用户选择延迟
845
+ writePendingUpdate(installDir, tempPath, version);
846
+ console.log(` [INFO] Update will be applied automatically on next startup.`);
847
+ return true; // 下载成功,只是替换延迟
848
+ }
849
+ }
850
+
851
+ // 其他错误
852
+ console.log(` [WARN] Replace failed with unexpected error. Deferring to next startup.`);
853
+ writePendingUpdate(installDir, tempPath, version);
854
+ return true; // 下载成功,只是替换延迟
855
+ }
856
+
614
857
  async function downloadFromServer(platform, arch, version) {
615
858
  const binName = platform === 'win32' ? 'sciagent.exe' : 'sciagent';
616
859
  const downloadUrl = `${RELEASE_SERVER_URL}/api/releases/download/${platform}/${arch}/${version}`;
@@ -623,24 +866,28 @@ async function downloadFromServer(platform, arch, version) {
623
866
 
624
867
  const targetPath = path.join(installDir, binName);
625
868
 
869
+ // 下载到临时目录
870
+ const tempDir = path.join(os.tmpdir(), 'sciagent-update');
871
+ fs.mkdirSync(tempDir, { recursive: true });
872
+ const tempPath = path.join(tempDir, binName);
873
+ try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) {}
874
+
626
875
  try {
627
- await downloadFile(downloadUrl, targetPath, 600000); // 10 分钟超时
876
+ await downloadFile(downloadUrl, tempPath, 600000); // 10 分钟超时
628
877
 
629
878
  // 验证下载
630
- if (fs.existsSync(targetPath)) {
631
- const stats = fs.statSync(targetPath);
879
+ if (fs.existsSync(tempPath)) {
880
+ const stats = fs.statSync(tempPath);
632
881
  if (stats.size > 10 * 1024 * 1024) { // 至少 10MB
633
- console.log(` ✅ 下载成功: ${targetPath} (${(stats.size / (1024 * 1024)).toFixed(1)} MB)`);
882
+ console.log(` ✅ 下载到临时文件: ${tempPath} (${(stats.size / (1024 * 1024)).toFixed(1)} MB)`);
634
883
 
635
884
  // Linux/macOS 添加执行权限
636
885
  if (platform !== 'win32') {
637
- fs.chmodSync(targetPath, 0o755);
886
+ fs.chmodSync(tempPath, 0o755);
638
887
  }
639
888
 
640
- // 写入版本文件
641
- fs.writeFileSync(path.join(installDir, '.version'), version);
642
-
643
- return true;
889
+ // 尝试替换(处理文件锁定)
890
+ return await applyDownloadedBinary(tempPath, targetPath, installDir, version, platform);
644
891
  }
645
892
  }
646
893