chatccc 0.2.234 → 0.2.236
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -20
- package/config.sample.json +9 -9
- package/package.json +1 -2
- package/src/__tests__/claude-raw-stream-log.test.ts +16 -7
- package/src/__tests__/claude-sdk-installer.test.ts +285 -0
- package/src/__tests__/restart.test.ts +232 -177
- package/src/__tests__/web-ui.test.ts +436 -326
- package/src/adapters/claude-adapter.ts +79 -26
- package/src/claude-sdk-installer.ts +324 -0
- package/src/orchestrator.ts +2543 -2517
- package/src/web-ui.ts +218 -47
|
@@ -1,17 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { delimiter, dirname, join } from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
getSessionInfo as sdkGetSessionInfo,
|
|
8
|
-
unstable_v2_createSession,
|
|
9
|
-
unstable_v2_resumeSession,
|
|
10
|
-
type SDKMessage,
|
|
11
|
-
type SDKSession,
|
|
12
|
-
type SDKSessionOptions,
|
|
13
|
-
type EffortLevel,
|
|
14
|
-
} from "@anthropic-ai/claude-agent-sdk";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
5
|
|
|
16
6
|
import type {
|
|
17
7
|
CreateSessionResult,
|
|
@@ -31,6 +21,7 @@ import {
|
|
|
31
21
|
createRawStreamLog,
|
|
32
22
|
type RawStreamLogHandle,
|
|
33
23
|
} from "./raw-stream-log.ts";
|
|
24
|
+
import { getClaudeSdkEntryPath } from "../claude-sdk-installer.ts";
|
|
34
25
|
|
|
35
26
|
const PROJECT_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
36
27
|
const CLAUDE_SPECIFIC_PROMPT_PATH = join(
|
|
@@ -303,16 +294,78 @@ export function buildClaudePromptText(
|
|
|
303
294
|
].join("\n");
|
|
304
295
|
}
|
|
305
296
|
|
|
306
|
-
|
|
297
|
+
export interface ClaudeSdkSessionOptions {
|
|
298
|
+
cwd?: string;
|
|
307
299
|
model?: string;
|
|
308
300
|
abortController?: AbortController;
|
|
309
301
|
autoCompactEnabled?: boolean;
|
|
310
|
-
effort?:
|
|
302
|
+
effort?: string | number;
|
|
311
303
|
maxTurns?: number;
|
|
312
304
|
mcpServers?: Record<string, unknown>;
|
|
313
305
|
skills?: "all";
|
|
314
306
|
stderr?: (data: string) => void;
|
|
315
|
-
|
|
307
|
+
settingSources?: SettingSource[];
|
|
308
|
+
permissionMode?: string;
|
|
309
|
+
settings?: unknown;
|
|
310
|
+
allowDangerouslySkipPermissions?: boolean;
|
|
311
|
+
env?: Record<string, string | undefined>;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** SDK 会话的最小接口(实际由动态加载的 @anthropic-ai/claude-agent-sdk 提供) */
|
|
315
|
+
export interface ClaudeSdkSession {
|
|
316
|
+
send(text: string): Promise<void>;
|
|
317
|
+
stream(): AsyncIterable<SdkMessageLike>;
|
|
318
|
+
close(): void;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** SDK getSessionInfo 的返回形状(只取本项目用到的字段) */
|
|
322
|
+
export interface ClaudeSdkSessionInfo {
|
|
323
|
+
sessionId: string;
|
|
324
|
+
cwd?: string;
|
|
325
|
+
summary: string;
|
|
326
|
+
lastModified: number;
|
|
327
|
+
[key: string]: unknown;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** 动态加载的 SDK 模块接口(unstable_v2_* API 保持与 SDK 0.2.133 一致) */
|
|
331
|
+
export interface ClaudeSdkModule {
|
|
332
|
+
unstable_v2_createSession(options: unknown): ClaudeSdkSession;
|
|
333
|
+
unstable_v2_resumeSession(sessionId: string, options: unknown): ClaudeSdkSession;
|
|
334
|
+
getSessionInfo(
|
|
335
|
+
sessionId: string,
|
|
336
|
+
opts?: { dir?: string },
|
|
337
|
+
): Promise<ClaudeSdkSessionInfo | null | undefined>;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
let sdkModulePromise: Promise<ClaudeSdkModule> | null = null;
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 按需加载 Claude Code 引擎(Agent SDK)。SDK 不在 chatccc 的 dependencies 中,
|
|
344
|
+
* 首次使用前需在设置页「Claude Code」卡片点「安装引擎」下载到 ~/.chatccc/claude-sdk/。
|
|
345
|
+
*/
|
|
346
|
+
export function loadClaudeSdkModule(): Promise<ClaudeSdkModule> {
|
|
347
|
+
if (!sdkModulePromise) {
|
|
348
|
+
sdkModulePromise = import(pathToFileURL(getClaudeSdkEntryPath()).href)
|
|
349
|
+
.then((m) => m as unknown as ClaudeSdkModule)
|
|
350
|
+
.catch((err: unknown) => {
|
|
351
|
+
sdkModulePromise = null;
|
|
352
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
353
|
+
throw new Error(
|
|
354
|
+
`Claude Code 引擎(SDK)未安装或加载失败:${detail}。` +
|
|
355
|
+
"请在设置页「Claude Code」卡片点击「安装引擎」后重试。",
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return sdkModulePromise;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* 测试钩子:注入假 SDK 模块(vitest 的 vi.mock 无法拦截变量路径的动态 import)。
|
|
364
|
+
* 传入 null 恢复为真实按需加载。
|
|
365
|
+
*/
|
|
366
|
+
export function __setClaudeSdkModuleForTest(module: ClaudeSdkModule | null): void {
|
|
367
|
+
sdkModulePromise = module ? Promise.resolve(module) : null;
|
|
368
|
+
}
|
|
316
369
|
|
|
317
370
|
function buildSdkOptions(args: {
|
|
318
371
|
cwd: string;
|
|
@@ -392,10 +445,6 @@ function buildSdkOptions(args: {
|
|
|
392
445
|
return options;
|
|
393
446
|
}
|
|
394
447
|
|
|
395
|
-
function toMessageLike(message: SDKMessage): SdkMessageLike {
|
|
396
|
-
return message as unknown as SdkMessageLike;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
448
|
function safeRawStreamJson(value: unknown): string {
|
|
400
449
|
try {
|
|
401
450
|
return JSON.stringify(value) ?? "null";
|
|
@@ -421,12 +470,12 @@ function bridgeAbortSignal(
|
|
|
421
470
|
return () => signal.removeEventListener("abort", onAbort);
|
|
422
471
|
}
|
|
423
472
|
|
|
424
|
-
function closeSdkSession(session:
|
|
473
|
+
function closeSdkSession(session: ClaudeSdkSession): void {
|
|
425
474
|
session.close();
|
|
426
475
|
}
|
|
427
476
|
|
|
428
|
-
function toSdkSessionOptions(options: ClaudeSdkSessionOptions):
|
|
429
|
-
return options
|
|
477
|
+
function toSdkSessionOptions(options: ClaudeSdkSessionOptions): unknown {
|
|
478
|
+
return options;
|
|
430
479
|
}
|
|
431
480
|
|
|
432
481
|
class ClaudeAdapter implements ToolAdapter {
|
|
@@ -460,8 +509,9 @@ class ClaudeAdapter implements ToolAdapter {
|
|
|
460
509
|
removeAbortListener?.();
|
|
461
510
|
throw new Error("Claude session creation aborted");
|
|
462
511
|
}
|
|
512
|
+
const sdk = await loadClaudeSdkModule();
|
|
463
513
|
let sessionId: string | undefined;
|
|
464
|
-
const session = unstable_v2_createSession(
|
|
514
|
+
const session = sdk.unstable_v2_createSession(
|
|
465
515
|
toSdkSessionOptions(buildSdkOptions({
|
|
466
516
|
cwd,
|
|
467
517
|
model: this.model,
|
|
@@ -479,7 +529,7 @@ class ClaudeAdapter implements ToolAdapter {
|
|
|
479
529
|
try {
|
|
480
530
|
await session.send("ok");
|
|
481
531
|
for await (const raw of session.stream()) {
|
|
482
|
-
const msg =
|
|
532
|
+
const msg = raw;
|
|
483
533
|
if (msg.session_id && !sessionId) {
|
|
484
534
|
sessionId = msg.session_id;
|
|
485
535
|
await this.metaStore.set(sessionId, {
|
|
@@ -514,7 +564,9 @@ class ClaudeAdapter implements ToolAdapter {
|
|
|
514
564
|
const rawLogConfig = config.rawStreamLogs.claude;
|
|
515
565
|
let rawLog: RawStreamLogHandle | null = null;
|
|
516
566
|
|
|
517
|
-
const
|
|
567
|
+
const sdk = await loadClaudeSdkModule();
|
|
568
|
+
|
|
569
|
+
const session = sdk.unstable_v2_resumeSession(
|
|
518
570
|
sessionId,
|
|
519
571
|
toSdkSessionOptions(buildSdkOptions({
|
|
520
572
|
cwd,
|
|
@@ -554,7 +606,7 @@ class ClaudeAdapter implements ToolAdapter {
|
|
|
554
606
|
break;
|
|
555
607
|
}
|
|
556
608
|
|
|
557
|
-
const msg =
|
|
609
|
+
const msg = raw;
|
|
558
610
|
rawLog?.writeLine(safeRawStreamJson(msg));
|
|
559
611
|
if (msg.type === "system" && msg.subtype === "init" && msg.session_id) {
|
|
560
612
|
const meta: { cwd?: string; model?: string } = {};
|
|
@@ -585,7 +637,8 @@ class ClaudeAdapter implements ToolAdapter {
|
|
|
585
637
|
const meta = await this.metaStore.get(sessionId);
|
|
586
638
|
|
|
587
639
|
try {
|
|
588
|
-
const
|
|
640
|
+
const sdk = await loadClaudeSdkModule();
|
|
641
|
+
const sdkInfo = await sdk.getSessionInfo(
|
|
589
642
|
sessionId,
|
|
590
643
|
meta?.cwd ? { dir: meta.cwd } : undefined,
|
|
591
644
|
);
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// =============================================================================
|
|
2
|
+
// claude-sdk-installer.ts — Claude Code 引擎(Agent SDK)按需安装器
|
|
3
|
+
// =============================================================================
|
|
4
|
+
// 背景:@anthropic-ai/claude-agent-sdk 通过 optionalDependencies 内置各平台
|
|
5
|
+
// 的原生 CLI 二进制(Windows 上 claude.exe 约 215MB),若作为 chatccc 的硬依赖,
|
|
6
|
+
// 所有用户(包括不用 Claude Code 的)都要白白下载 220MB+。
|
|
7
|
+
//
|
|
8
|
+
// 方案:把 SDK 从 chatccc 的 dependencies 中移除,改为**按需安装**到
|
|
9
|
+
// `~/.chatccc/claude-sdk/`:
|
|
10
|
+
// - 用户未启用 Claude Code → 完全不下载,npm 包体积大幅下降;
|
|
11
|
+
// - 用户在设置页打开 Claude Code 开关时,点「安装引擎」触发后台安装,
|
|
12
|
+
// 带进度条(状态式:检测环境 → 下载中 → 安装中 → 完成/失败)。
|
|
13
|
+
//
|
|
14
|
+
// 设计约束:
|
|
15
|
+
// - **不 import config.ts**(config.ts 顶层有 loadConfig 副作用,web-ui.ts
|
|
16
|
+
// 依赖本模块,间接 import config.ts 会污染依赖 web-ui.ts 的单测)。
|
|
17
|
+
// - 始终使用 SDK 内置的 CLI 二进制(用户拍板档位 b),不依赖用户自装 CLI。
|
|
18
|
+
// - 安装目录可注入(dir 参数),便于单测用临时目录,不碰真实用户目录。
|
|
19
|
+
// =============================================================================
|
|
20
|
+
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
22
|
+
import {
|
|
23
|
+
existsSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
readdirSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
rmSync,
|
|
28
|
+
statSync,
|
|
29
|
+
unlinkSync,
|
|
30
|
+
writeFileSync,
|
|
31
|
+
type Dirent,
|
|
32
|
+
} from "node:fs";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { pathToFileURL } from "node:url";
|
|
36
|
+
|
|
37
|
+
/** 与当前代码配套的 SDK 版本(package.json 移除依赖后此常量成为唯一版本来源) */
|
|
38
|
+
export const CLAUDE_SDK_VERSION = "0.2.133";
|
|
39
|
+
|
|
40
|
+
/** 按需安装目录:~/.chatccc/claude-sdk */
|
|
41
|
+
export const CLAUDE_SDK_DIR = join(homedir(), ".chatccc", "claude-sdk");
|
|
42
|
+
|
|
43
|
+
const SDK_PKG_REL = join("node_modules", "@anthropic-ai", "claude-agent-sdk", "package.json");
|
|
44
|
+
const SDK_ENTRY_REL = join("node_modules", "@anthropic-ai", "claude-agent-sdk", "sdk.mjs");
|
|
45
|
+
const LOCK_FILE = ".installing";
|
|
46
|
+
|
|
47
|
+
/** 估算的完整安装体积(含内置 CLI 二进制),用于进度条展示 */
|
|
48
|
+
export const CLAUDE_SDK_EXPECTED_BYTES = 240 * 1024 * 1024;
|
|
49
|
+
|
|
50
|
+
export type SdkInstallPhase =
|
|
51
|
+
| "idle"
|
|
52
|
+
| "detecting"
|
|
53
|
+
| "downloading"
|
|
54
|
+
| "installing"
|
|
55
|
+
| "done"
|
|
56
|
+
| "error";
|
|
57
|
+
|
|
58
|
+
export interface SdkInstallProgress {
|
|
59
|
+
phase: SdkInstallPhase;
|
|
60
|
+
/** 0-100 */
|
|
61
|
+
percent: number;
|
|
62
|
+
message: string;
|
|
63
|
+
error?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface SdkInstallOptions {
|
|
67
|
+
/** 安装目录(测试注入临时目录) */
|
|
68
|
+
dir?: string;
|
|
69
|
+
/** npm 命令(测试注入 fake) */
|
|
70
|
+
npmCommand?: string;
|
|
71
|
+
/** 期望版本(默认 CLAUDE_SDK_VERSION) */
|
|
72
|
+
expectedVersion?: string;
|
|
73
|
+
/** 进度回调 */
|
|
74
|
+
onProgress?: (p: SdkInstallProgress) => void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// 状态查询
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/** SDK 入口文件绝对路径(供动态 import) */
|
|
82
|
+
export function getClaudeSdkEntryPath(dir: string = CLAUDE_SDK_DIR): string {
|
|
83
|
+
return join(dir, SDK_ENTRY_REL);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 是否已安装(node_modules 里存在 SDK package.json 即视为已装) */
|
|
87
|
+
export function isClaudeSdkInstalled(dir: string = CLAUDE_SDK_DIR): boolean {
|
|
88
|
+
return existsSync(join(dir, SDK_PKG_REL));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 已安装版本号;未安装/解析失败返回 null */
|
|
92
|
+
export function getClaudeSdkInstalledVersion(dir: string = CLAUDE_SDK_DIR): string | null {
|
|
93
|
+
try {
|
|
94
|
+
const raw = readFileSync(join(dir, SDK_PKG_REL), "utf8");
|
|
95
|
+
const pkg = JSON.parse(raw) as { version?: unknown };
|
|
96
|
+
return typeof pkg.version === "string" ? pkg.version : null;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 是否正在安装(锁文件存在且 pid 存活);pid 已死视为过期锁并自动清理 */
|
|
103
|
+
export function isClaudeSdkInstalling(dir: string = CLAUDE_SDK_DIR): boolean {
|
|
104
|
+
const lockPath = join(dir, LOCK_FILE);
|
|
105
|
+
if (!existsSync(lockPath)) return false;
|
|
106
|
+
try {
|
|
107
|
+
const pid = Number.parseInt(readFileSync(lockPath, "utf8").trim(), 10);
|
|
108
|
+
if (Number.isFinite(pid) && pid > 0 && isPidAlive(pid)) return true;
|
|
109
|
+
} catch {
|
|
110
|
+
// 锁文件损坏 → 按过期处理,走清理
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
unlinkSync(lockPath);
|
|
114
|
+
} catch {
|
|
115
|
+
// 清理失败不阻塞判断
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isPidAlive(pid: number): boolean {
|
|
121
|
+
try {
|
|
122
|
+
process.kill(pid, 0);
|
|
123
|
+
return true;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// 安装(后台任务 + 进度)
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/** 最近一次安装的进度快照,供 web-ui 轮询 */
|
|
134
|
+
let lastProgress: SdkInstallProgress = { phase: "idle", percent: 0, message: "" };
|
|
135
|
+
let installInProgress = false;
|
|
136
|
+
|
|
137
|
+
export function getLastInstallProgress(): SdkInstallProgress {
|
|
138
|
+
return { ...lastProgress, ...(lastProgress.error ? { error: lastProgress.error } : {}) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function isInstallRunning(): boolean {
|
|
142
|
+
return installInProgress;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function setProgress(p: SdkInstallProgress, onProgress?: (p: SdkInstallProgress) => void): void {
|
|
146
|
+
lastProgress = { ...p };
|
|
147
|
+
onProgress?.(lastProgress);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 启动按需安装。返回 Promise,安装完成 resolve、失败 reject。
|
|
152
|
+
* 调用方通常不 await(后台执行),通过 onProgress / getLastInstallProgress() 获取进度。
|
|
153
|
+
*/
|
|
154
|
+
export async function installClaudeSdk(options: SdkInstallOptions = {}): Promise<void> {
|
|
155
|
+
const dir = options.dir ?? CLAUDE_SDK_DIR;
|
|
156
|
+
const npmCommand = options.npmCommand ?? "npm";
|
|
157
|
+
const expected = options.expectedVersion ?? CLAUDE_SDK_VERSION;
|
|
158
|
+
const onProgress = options.onProgress;
|
|
159
|
+
|
|
160
|
+
if (installInProgress) {
|
|
161
|
+
throw new Error("Claude Code 引擎正在安装中,请等待完成后再试。");
|
|
162
|
+
}
|
|
163
|
+
if (isClaudeSdkInstalling(dir)) {
|
|
164
|
+
throw new Error("检测到另一个安装任务正在进行(锁文件存在),请稍后再试。");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const installedVersion = isClaudeSdkInstalled(dir) ? getClaudeSdkInstalledVersion(dir) : null;
|
|
168
|
+
if (installedVersion === expected) {
|
|
169
|
+
setProgress({ phase: "done", percent: 100, message: `已安装 v${installedVersion}` }, onProgress);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (installedVersion !== null && installedVersion !== expected) {
|
|
173
|
+
setProgress(
|
|
174
|
+
{
|
|
175
|
+
phase: "detecting",
|
|
176
|
+
percent: 3,
|
|
177
|
+
message: `已装 v${installedVersion} ≠ 期望 v${expected},开始重装…`,
|
|
178
|
+
},
|
|
179
|
+
onProgress,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
installInProgress = true;
|
|
184
|
+
mkdirSync(dir, { recursive: true });
|
|
185
|
+
writeFileSync(join(dir, LOCK_FILE), String(process.pid), "utf8");
|
|
186
|
+
|
|
187
|
+
setProgress({ phase: "detecting", percent: 2, message: "检测环境…" }, onProgress);
|
|
188
|
+
|
|
189
|
+
return new Promise<void>((resolve, reject) => {
|
|
190
|
+
const child = spawn(
|
|
191
|
+
npmCommand,
|
|
192
|
+
[
|
|
193
|
+
"install",
|
|
194
|
+
"--prefix",
|
|
195
|
+
dir,
|
|
196
|
+
`@anthropic-ai/claude-agent-sdk@${expected}`,
|
|
197
|
+
"--no-audit",
|
|
198
|
+
"--no-fund",
|
|
199
|
+
"--loglevel=http",
|
|
200
|
+
],
|
|
201
|
+
{
|
|
202
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
203
|
+
windowsHide: true,
|
|
204
|
+
// Windows 下 npm 是 npm.cmd,需要 shell 才能解析
|
|
205
|
+
shell: process.platform === "win32",
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
let stderrBuf = "";
|
|
210
|
+
let requestCount = 0;
|
|
211
|
+
let sizeTimer: ReturnType<typeof setInterval> | null = null;
|
|
212
|
+
|
|
213
|
+
const updateBySize = (): void => {
|
|
214
|
+
const size = dirSizeBytes(dir);
|
|
215
|
+
const sizePercent = Math.min((size / CLAUDE_SDK_EXPECTED_BYTES) * 90, 90);
|
|
216
|
+
const requestPercent = Math.min(5 + requestCount * 4, 70);
|
|
217
|
+
const percent = Math.max(sizePercent, requestPercent);
|
|
218
|
+
const mb = (size / 1048576).toFixed(0);
|
|
219
|
+
setProgress(
|
|
220
|
+
{
|
|
221
|
+
phase: "downloading",
|
|
222
|
+
percent: Math.min(percent, 94),
|
|
223
|
+
message: `下载中… 已下载 ${mb} MB / 约 220 MB`,
|
|
224
|
+
},
|
|
225
|
+
onProgress,
|
|
226
|
+
);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
230
|
+
const text = chunk.toString();
|
|
231
|
+
// npm --loglevel=http 每完成一个请求输出一行 "npm http fetch GET 200 ..."
|
|
232
|
+
requestCount += (text.match(/http fetch GET|GET \d{3}/g) ?? []).length;
|
|
233
|
+
updateBySize();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
237
|
+
stderrBuf += chunk.toString();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
child.on("error", (err) => {
|
|
241
|
+
cleanupAfterFailure(dir, err, onProgress, () => {
|
|
242
|
+
installInProgress = false;
|
|
243
|
+
reject(err);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
child.on("close", (code) => {
|
|
248
|
+
if (sizeTimer) clearInterval(sizeTimer);
|
|
249
|
+
if (code === 0 && isClaudeSdkInstalled(dir)) {
|
|
250
|
+
const version = getClaudeSdkInstalledVersion(dir);
|
|
251
|
+
installInProgress = false;
|
|
252
|
+
try {
|
|
253
|
+
unlinkSync(join(dir, LOCK_FILE));
|
|
254
|
+
} catch {
|
|
255
|
+
// 锁文件不存在可忽略
|
|
256
|
+
}
|
|
257
|
+
setProgress(
|
|
258
|
+
{ phase: "done", percent: 100, message: `安装完成(v${version ?? expected})` },
|
|
259
|
+
onProgress,
|
|
260
|
+
);
|
|
261
|
+
resolve();
|
|
262
|
+
} else {
|
|
263
|
+
const tail = stderrBuf.trim().split("\n").slice(-5).join(" | ");
|
|
264
|
+
const detail = code !== 0 ? `npm 退出码 ${code}` : "安装后校验未通过";
|
|
265
|
+
const message = tail ? `${detail}:${tail.slice(0, 400)}` : detail;
|
|
266
|
+
cleanupAfterFailure(dir, new Error(message), onProgress, () => {
|
|
267
|
+
installInProgress = false;
|
|
268
|
+
reject(new Error(message));
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// 定期按目录体积刷新进度(大文件下载时 stdout 事件稀疏)
|
|
274
|
+
sizeTimer = setInterval(() => {
|
|
275
|
+
if (installInProgress) updateBySize();
|
|
276
|
+
}, 400);
|
|
277
|
+
if (typeof sizeTimer.unref === "function") sizeTimer.unref();
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function cleanupAfterFailure(
|
|
282
|
+
dir: string,
|
|
283
|
+
err: Error,
|
|
284
|
+
onProgress: ((p: SdkInstallProgress) => void) | undefined,
|
|
285
|
+
after: () => void,
|
|
286
|
+
): void {
|
|
287
|
+
setProgress(
|
|
288
|
+
{ phase: "error", percent: 0, message: "安装失败", error: err.message.slice(0, 500) },
|
|
289
|
+
onProgress,
|
|
290
|
+
);
|
|
291
|
+
try {
|
|
292
|
+
rmSync(dir, { recursive: true, force: true });
|
|
293
|
+
} catch {
|
|
294
|
+
// 清理失败不阻塞
|
|
295
|
+
}
|
|
296
|
+
after();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function dirSizeBytes(dir: string): number {
|
|
300
|
+
let total = 0;
|
|
301
|
+
const stack = [dir];
|
|
302
|
+
while (stack.length > 0) {
|
|
303
|
+
const current = stack.pop()!;
|
|
304
|
+
let entries: Dirent[];
|
|
305
|
+
try {
|
|
306
|
+
entries = readdirSync(current, { withFileTypes: true });
|
|
307
|
+
} catch {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
for (const entry of entries) {
|
|
311
|
+
const full = join(current, entry.name);
|
|
312
|
+
if (entry.isDirectory()) {
|
|
313
|
+
stack.push(full);
|
|
314
|
+
} else if (entry.isFile()) {
|
|
315
|
+
try {
|
|
316
|
+
total += statSync(full).size;
|
|
317
|
+
} catch {
|
|
318
|
+
// 文件被并发删除可忽略
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return total;
|
|
324
|
+
}
|