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
package/README.md
CHANGED
|
@@ -210,9 +210,29 @@ chatccc
|
|
|
210
210
|
|
|
211
211
|
Claude Code、Cursor 和 Codex 需要对应的本地工具;CCC Agent 内置于 ChatCCC,开箱即用,**模型接入不限于 DeepSeek**——它走 OpenAI 兼容协议,任意兼容端点都可以直接替换(详见下文 CCC Agent)。
|
|
212
212
|
|
|
213
|
+
#### CCC Agent
|
|
214
|
+
|
|
215
|
+
CCC Agent 是 ChatCCC 内置的编程 Agent,不需要额外安装 CLI,开箱即用。在首次配置向导或 Web 管理页中启用后,填写 API Key、Base URL 和模型即可使用;它可以设为 `/new` 的默认 Agent,也可以通过 `/new ccc` 显式创建会话。
|
|
216
|
+
|
|
217
|
+
**API 支持不限于 DeepSeek。** CCC Agent 底层使用 OpenAI 兼容协议(`@ai-sdk/openai-compatible`),DeepSeek 只是出厂默认端点。`ccc.DEEPSEEK_API_KEY` / `ccc.DEEPSEEK_BASE_URL` 可以指向**任意 OpenAI 兼容服务**,例如:
|
|
218
|
+
|
|
219
|
+
| 服务 | Base URL 示例 | 说明 |
|
|
220
|
+
| --- | --- | --- |
|
|
221
|
+
| DeepSeek(默认) | `https://api.deepseek.com/v1` | 官方端点,支持 `/usage` 余额查询 |
|
|
222
|
+
| OpenAI | `https://api.openai.com/v1` | 官方模型 |
|
|
223
|
+
| Kimi / Moonshot | `https://api.moonshot.cn/v1` | 国内直连 |
|
|
224
|
+
| 通义千问 / 智谱 GLM / 豆包 / MiniMax | 各家 `…/v1` 端点 | 国内 OpenAI 兼容服务 |
|
|
225
|
+
| Ollama / vLLM / LM Studio | `http://localhost:11434/v1` | 本地或自建推理服务 |
|
|
226
|
+
|
|
227
|
+
更换服务只需把 `ccc.DEEPSEEK_BASE_URL` 改为对应端点、`ccc.DEEPSEEK_API_KEY` 改为对应 Key、`ccc.model` 改为目标模型名即可。`reasoning_effort` 等 DeepSeek 扩展字段对不认识的模型会自动忽略,不影响其他服务。余额查询仅对官方 DeepSeek 域名(`api.deepseek.com`)生效,指向其他端点时自动跳过,不影响对话。
|
|
228
|
+
|
|
229
|
+
`ccc.alternativeModel` 是单个备选模型,只会加入 `/model` 的人工切换列表,不会在请求失败时自动重试或切换,避免重复执行带副作用的工具调用。
|
|
230
|
+
|
|
213
231
|
#### Claude Code
|
|
214
232
|
|
|
215
|
-
|
|
233
|
+
ChatCCC 通过 Anthropic Claude Agent SDK 调用 Claude Code 能力。SDK 引擎(含 Claude Code CLI 原生二进制,约 220MB)**按需下载**:在首次配置向导或 Web 管理页的「Claude Code」卡片点「安装 Claude Code SDK」(开关从关闭切到打开时也会弹出安装确认),页面会显示下载进度条,完成后即可使用。**只有启用 Claude Code 的用户才需要下载**,不使用 Claude Code 的安装不包含该引擎,chatccc 主包体积保持精简。
|
|
234
|
+
|
|
235
|
+
SDK 始终使用其内置的 Claude Code CLI(档位 b),不依赖用户自行安装的 `claude` 命令。本机完成 Claude Code 登录后即可使用(SDK 复用 `~/.claude` 登录态);同时支持官方和第三方 Anthropic 兼容 API:使用官方服务无需额外配置,使用第三方 API 则填写 `claude.apiKey` 和 `claude.baseUrl`。`claude.model`、`claude.subagentModel`、`claude.effort`、`claude.apiKey`、`claude.baseUrl` 均为选填;`claude.maxTurn` 控制每次对话的最大轮数(默认 0,即无限制)。填写后会把对应配置传给 Claude Agent SDK;留空则以 `~/.claude/settings.json` 为准。
|
|
216
236
|
|
|
217
237
|
#### Cursor Agent CLI
|
|
218
238
|
|
|
@@ -239,24 +259,6 @@ codex --version
|
|
|
239
259
|
|
|
240
260
|
Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理,也可以在 `config.json` 中覆盖。
|
|
241
261
|
|
|
242
|
-
#### CCC Agent
|
|
243
|
-
|
|
244
|
-
CCC Agent 是 ChatCCC 内置的编程 Agent,不需要额外安装 CLI,开箱即用。在首次配置向导或 Web 管理页中启用后,填写 API Key、Base URL 和模型即可使用;它可以设为 `/new` 的默认 Agent,也可以通过 `/new ccc` 显式创建会话。
|
|
245
|
-
|
|
246
|
-
**API 支持不限于 DeepSeek。** CCC Agent 底层使用 OpenAI 兼容协议(`@ai-sdk/openai-compatible`),DeepSeek 只是出厂默认端点。`ccc.DEEPSEEK_API_KEY` / `ccc.DEEPSEEK_BASE_URL` 可以指向**任意 OpenAI 兼容服务**,例如:
|
|
247
|
-
|
|
248
|
-
| 服务 | Base URL 示例 | 说明 |
|
|
249
|
-
| --- | --- | --- |
|
|
250
|
-
| DeepSeek(默认) | `https://api.deepseek.com/v1` | 官方端点,支持 `/usage` 余额查询 |
|
|
251
|
-
| OpenAI | `https://api.openai.com/v1` | 官方模型 |
|
|
252
|
-
| Kimi / Moonshot | `https://api.moonshot.cn/v1` | 国内直连 |
|
|
253
|
-
| 通义千问 / 智谱 GLM / 豆包 / MiniMax | 各家 `…/v1` 端点 | 国内 OpenAI 兼容服务 |
|
|
254
|
-
| Ollama / vLLM / LM Studio | `http://localhost:11434/v1` | 本地或自建推理服务 |
|
|
255
|
-
|
|
256
|
-
更换服务只需把 `ccc.DEEPSEEK_BASE_URL` 改为对应端点、`ccc.DEEPSEEK_API_KEY` 改为对应 Key、`ccc.model` 改为目标模型名即可。`reasoning_effort` 等 DeepSeek 扩展字段对不认识的模型会自动忽略,不影响其他服务。余额查询仅对官方 DeepSeek 域名(`api.deepseek.com`)生效,指向其他端点时自动跳过,不影响对话。
|
|
257
|
-
|
|
258
|
-
`ccc.alternativeModel` 是单个备选模型,只会加入 `/model` 的人工切换列表,不会在请求失败时自动重试或切换,避免重复执行带副作用的工具调用。
|
|
259
|
-
|
|
260
262
|
#### 可选:Chrome CDP
|
|
261
263
|
|
|
262
264
|
常驻 Chrome CDP 开关用于维护本机 Chrome DevTools Protocol 端口;Codex `/usage` 可通过它读取 ChatGPT 订阅到期时间和剩余天数。
|
|
@@ -408,4 +410,4 @@ CCC Agent 是 ChatCCC 内置的编程 Agent,不需要额外安装 CLI,开箱
|
|
|
408
410
|
|
|
409
411
|
## 技术栈
|
|
410
412
|
|
|
411
|
-
TypeScript / Node.js >= 20 / tsx / AI SDK / Anthropic Claude Agent SDK
|
|
413
|
+
TypeScript / Node.js >= 20 / tsx / AI SDK / Anthropic Claude Agent SDK(按需下载,仅启用 Claude Code 时安装)/ Cursor Agent CLI / Codex CLI / 飞书 WebSocket API / CardKit / 微信 iLink
|
package/config.sample.json
CHANGED
|
@@ -44,6 +44,15 @@
|
|
|
44
44
|
"keepCompleted": false
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
|
+
"ccc": {
|
|
48
|
+
"enabled": false,
|
|
49
|
+
"defaultAgent": false,
|
|
50
|
+
"DEEPSEEK_API_KEY": "",
|
|
51
|
+
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
|
|
52
|
+
"model": "deepseek-v4-pro",
|
|
53
|
+
"alternativeModel": "",
|
|
54
|
+
"effort": ""
|
|
55
|
+
},
|
|
47
56
|
"claude": {
|
|
48
57
|
"enabled": false,
|
|
49
58
|
"defaultAgent": true,
|
|
@@ -71,14 +80,5 @@
|
|
|
71
80
|
"alternativeModel": "",
|
|
72
81
|
"effort": "",
|
|
73
82
|
"fastMode": false
|
|
74
|
-
},
|
|
75
|
-
"ccc": {
|
|
76
|
-
"enabled": false,
|
|
77
|
-
"defaultAgent": false,
|
|
78
|
-
"DEEPSEEK_API_KEY": "",
|
|
79
|
-
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
|
|
80
|
-
"model": "deepseek-v4-pro",
|
|
81
|
-
"alternativeModel": "",
|
|
82
|
-
"effort": ""
|
|
83
83
|
}
|
|
84
84
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chatccc",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.236",
|
|
4
4
|
"description": "Feishu bot bridge for Claude Code",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -48,7 +48,6 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@ai-sdk/openai-compatible": "^2.0.47",
|
|
51
|
-
"@anthropic-ai/claude-agent-sdk": "0.2.133",
|
|
52
51
|
"@larksuiteoapi/node-sdk": "^1.59.0",
|
|
53
52
|
"@openilink/openilink-sdk-node": "^0.6.0",
|
|
54
53
|
"@vscode/ripgrep": "^1.18.0",
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
const resumeSessionMock = vi.hoisted(() => vi.fn());
|
|
4
4
|
const createRawStreamLogMock = vi.hoisted(() => vi.fn());
|
|
5
5
|
const rawLogWriteLineMock = vi.hoisted(() => vi.fn());
|
|
6
6
|
const rawLogCloseMock = vi.hoisted(() => vi.fn());
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
unstable_v2_createSession: vi.fn(),
|
|
11
|
-
unstable_v2_resumeSession: resumeSessionMock,
|
|
12
|
-
}));
|
|
8
|
+
// 注意:SDK 已从 chatccc 依赖中移除,改为按需安装 + 动态 import(变量路径),
|
|
9
|
+
// vi.mock 无法拦截;这里通过 __setClaudeSdkModuleForTest 注入假 SDK 模块。
|
|
13
10
|
|
|
14
11
|
vi.mock("../adapters/raw-stream-log.ts", () => ({
|
|
15
12
|
createRawStreamLog: createRawStreamLogMock,
|
|
16
13
|
}));
|
|
17
14
|
|
|
18
15
|
import { config } from "../config.ts";
|
|
19
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
__setClaudeSdkModuleForTest,
|
|
18
|
+
createClaudeAdapter,
|
|
19
|
+
} from "../adapters/claude-adapter.ts";
|
|
20
20
|
|
|
21
21
|
const originalRawStreamLogs = structuredClone(config.rawStreamLogs);
|
|
22
22
|
|
|
@@ -26,7 +26,16 @@ async function collect(iterable: AsyncIterable<unknown>): Promise<unknown[]> {
|
|
|
26
26
|
return events;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
__setClaudeSdkModuleForTest({
|
|
31
|
+
getSessionInfo: vi.fn(),
|
|
32
|
+
unstable_v2_createSession: vi.fn(),
|
|
33
|
+
unstable_v2_resumeSession: resumeSessionMock,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
29
37
|
afterEach(() => {
|
|
38
|
+
__setClaudeSdkModuleForTest(null);
|
|
30
39
|
resumeSessionMock.mockReset();
|
|
31
40
|
createRawStreamLogMock.mockReset();
|
|
32
41
|
rawLogWriteLineMock.mockReset();
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
CLAUDE_SDK_VERSION,
|
|
15
|
+
getClaudeSdkEntryPath,
|
|
16
|
+
getClaudeSdkInstalledVersion,
|
|
17
|
+
installClaudeSdk,
|
|
18
|
+
isClaudeSdkInstalled,
|
|
19
|
+
isClaudeSdkInstalling,
|
|
20
|
+
type SdkInstallProgress,
|
|
21
|
+
} from "../claude-sdk-installer.ts";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// 工具:fake npm(可执行脚本,模拟安装过程并伪造产物)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const tempDirs: string[] = [];
|
|
28
|
+
|
|
29
|
+
function makeTempDir(prefix = "claude-sdk-test-"): string {
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
31
|
+
tempDirs.push(dir);
|
|
32
|
+
return dir;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 伪造 SDK 产物:node_modules/@anthropic-ai/claude-agent-sdk/package.json */
|
|
36
|
+
function fakeInstallArtifact(dir: string, version: string = CLAUDE_SDK_VERSION): void {
|
|
37
|
+
const pkgDir = join(dir, "node_modules", "@anthropic-ai", "claude-agent-sdk");
|
|
38
|
+
mkdirSync(pkgDir, { recursive: true });
|
|
39
|
+
writeFileSync(
|
|
40
|
+
join(pkgDir, "package.json"),
|
|
41
|
+
JSON.stringify({ name: "@anthropic-ai/claude-agent-sdk", version }),
|
|
42
|
+
"utf8",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 生成 fake npm 可执行脚本,行为由环境变量控制(FAKE_NPM_FAIL / FAKE_NPM_SLEEP) */
|
|
47
|
+
function makeFakeNpmCommand(workDir: string): string {
|
|
48
|
+
const mjs = join(workDir, "fake-npm.mjs");
|
|
49
|
+
writeFileSync(
|
|
50
|
+
mjs,
|
|
51
|
+
[
|
|
52
|
+
'import fs from "node:fs";',
|
|
53
|
+
'const args = process.argv.slice(2);',
|
|
54
|
+
'const prefixIdx = args.indexOf("--prefix");',
|
|
55
|
+
'const dir = prefixIdx >= 0 ? args[prefixIdx + 1] : null;',
|
|
56
|
+
'const pkg = args.find((a) => a.startsWith("@anthropic-ai/claude-agent-sdk@"));',
|
|
57
|
+
'const version = pkg ? pkg.split("@").pop() : "0.0.0";',
|
|
58
|
+
'if (dir) {',
|
|
59
|
+
' fs.mkdirSync(dir + "/node_modules/@anthropic-ai/claude-agent-sdk", { recursive: true });',
|
|
60
|
+
' fs.writeFileSync(dir + "/node_modules/@anthropic-ai/claude-agent-sdk/package.json", JSON.stringify({ name: "@anthropic-ai/claude-agent-sdk", version }));',
|
|
61
|
+
"}",
|
|
62
|
+
'console.log("npm http fetch GET 200 https://registry.npmjs.org/fake");',
|
|
63
|
+
'console.log("npm http fetch GET 200 https://registry.npmjs.org/fake2");',
|
|
64
|
+
'if (process.env.FAKE_NPM_SLEEP) {',
|
|
65
|
+
" await new Promise((r) => setTimeout(r, Number(process.env.FAKE_NPM_SLEEP)));",
|
|
66
|
+
"}",
|
|
67
|
+
'if (process.env.FAKE_NPM_FAIL === "1") process.exit(1);',
|
|
68
|
+
"process.exit(0);",
|
|
69
|
+
"",
|
|
70
|
+
].join("\n"),
|
|
71
|
+
"utf8",
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (process.platform === "win32") {
|
|
75
|
+
const cmd = join(workDir, "fake-npm.cmd");
|
|
76
|
+
const winPath = mjs.replace(/\//g, "\\");
|
|
77
|
+
writeFileSync(
|
|
78
|
+
cmd,
|
|
79
|
+
["@echo off", `node "${winPath}" %*`, ""].join("\r\n"),
|
|
80
|
+
"utf8",
|
|
81
|
+
);
|
|
82
|
+
return cmd;
|
|
83
|
+
}
|
|
84
|
+
const sh = join(workDir, "fake-npm.sh");
|
|
85
|
+
writeFileSync(sh, `#!/bin/sh\nexec node "${mjs}" "$@"\n`, "utf8");
|
|
86
|
+
// chmod +x
|
|
87
|
+
try {
|
|
88
|
+
chmodSync(sh, 0o755);
|
|
89
|
+
} catch {
|
|
90
|
+
// POSIX only
|
|
91
|
+
}
|
|
92
|
+
return sh;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
for (const dir of tempDirs.splice(0)) {
|
|
99
|
+
try {
|
|
100
|
+
rmSync(dir, { recursive: true, force: true });
|
|
101
|
+
} catch {
|
|
102
|
+
// 清理失败不阻塞
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// 状态查询
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
describe("claude-sdk-installer 状态查询", () => {
|
|
112
|
+
it("getClaudeSdkEntryPath 指向 sdk.mjs 入口", () => {
|
|
113
|
+
const dir = makeTempDir();
|
|
114
|
+
const entry = getClaudeSdkEntryPath(dir);
|
|
115
|
+
expect(entry).toContain(join("node_modules", "@anthropic-ai", "claude-agent-sdk"));
|
|
116
|
+
expect(entry.endsWith("sdk.mjs")).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("isClaudeSdkInstalled:空目录为 false,有 package.json 为 true", () => {
|
|
120
|
+
const dir = makeTempDir();
|
|
121
|
+
expect(isClaudeSdkInstalled(dir)).toBe(false);
|
|
122
|
+
fakeInstallArtifact(dir);
|
|
123
|
+
expect(isClaudeSdkInstalled(dir)).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("getClaudeSdkInstalledVersion:读取版本号", () => {
|
|
127
|
+
const dir = makeTempDir();
|
|
128
|
+
expect(getClaudeSdkInstalledVersion(dir)).toBeNull();
|
|
129
|
+
fakeInstallArtifact(dir, "9.9.9");
|
|
130
|
+
expect(getClaudeSdkInstalledVersion(dir)).toBe("9.9.9");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("getClaudeSdkInstalledVersion:损坏 JSON 返回 null", () => {
|
|
134
|
+
const dir = makeTempDir();
|
|
135
|
+
const pkgDir = join(dir, "node_modules", "@anthropic-ai", "claude-agent-sdk");
|
|
136
|
+
mkdirSync(pkgDir, { recursive: true });
|
|
137
|
+
writeFileSync(join(pkgDir, "package.json"), "{ not json", "utf8");
|
|
138
|
+
expect(getClaudeSdkInstalledVersion(dir)).toBeNull();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("isClaudeSdkInstalling:无锁为 false", () => {
|
|
142
|
+
const dir = makeTempDir();
|
|
143
|
+
expect(isClaudeSdkInstalling(dir)).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("isClaudeSdkInstalling:活 pid 锁为 true", () => {
|
|
147
|
+
const dir = makeTempDir();
|
|
148
|
+
writeFileSync(join(dir, ".installing"), String(process.pid), "utf8");
|
|
149
|
+
expect(isClaudeSdkInstalling(dir)).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("isClaudeSdkInstalling:死 pid 锁自动清除并返回 false", () => {
|
|
153
|
+
const dir = makeTempDir();
|
|
154
|
+
writeFileSync(join(dir, ".installing"), "999999999", "utf8");
|
|
155
|
+
expect(isClaudeSdkInstalling(dir)).toBe(false);
|
|
156
|
+
expect(existsSync(join(dir, ".installing"))).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("isClaudeSdkInstalling:损坏锁自动清除并返回 false", () => {
|
|
160
|
+
const dir = makeTempDir();
|
|
161
|
+
writeFileSync(join(dir, ".installing"), "abc", "utf8");
|
|
162
|
+
expect(isClaudeSdkInstalling(dir)).toBe(false);
|
|
163
|
+
expect(existsSync(join(dir, ".installing"))).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// 安装流程
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
describe("installClaudeSdk", () => {
|
|
172
|
+
it("已装同版本 → 直接 done,不 spawn", async () => {
|
|
173
|
+
const dir = makeTempDir();
|
|
174
|
+
fakeInstallArtifact(dir, CLAUDE_SDK_VERSION);
|
|
175
|
+
const phases: string[] = [];
|
|
176
|
+
await installClaudeSdk({
|
|
177
|
+
dir,
|
|
178
|
+
npmCommand: "definitely-not-exists",
|
|
179
|
+
onProgress: (p) => phases.push(p.phase),
|
|
180
|
+
});
|
|
181
|
+
expect(phases).toEqual(["done"]);
|
|
182
|
+
expect(getClaudeSdkInstalledVersion(dir)).toBe(CLAUDE_SDK_VERSION);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("未安装 → fake npm 成功 → done + 锁清除 + 进度序列", async () => {
|
|
186
|
+
const dir = makeTempDir();
|
|
187
|
+
const npmCommand = makeFakeNpmCommand(makeTempDir());
|
|
188
|
+
const phases: string[] = [];
|
|
189
|
+
const messages: string[] = [];
|
|
190
|
+
await installClaudeSdk({
|
|
191
|
+
dir,
|
|
192
|
+
npmCommand,
|
|
193
|
+
onProgress: (p) => {
|
|
194
|
+
phases.push(p.phase);
|
|
195
|
+
messages.push(p.message);
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
expect(phases[0]).toBe("detecting");
|
|
199
|
+
expect(phases).toContain("downloading");
|
|
200
|
+
expect(phases[phases.length - 1]).toBe("done");
|
|
201
|
+
expect(isClaudeSdkInstalled(dir)).toBe(true);
|
|
202
|
+
expect(getClaudeSdkInstalledVersion(dir)).toBe(CLAUDE_SDK_VERSION);
|
|
203
|
+
expect(existsSync(join(dir, ".installing"))).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("已装不同版本 → 触发重装并成功", async () => {
|
|
207
|
+
const dir = makeTempDir();
|
|
208
|
+
fakeInstallArtifact(dir, "0.1.0");
|
|
209
|
+
const npmCommand = makeFakeNpmCommand(makeTempDir());
|
|
210
|
+
await installClaudeSdk({ dir, npmCommand });
|
|
211
|
+
expect(getClaudeSdkInstalledVersion(dir)).toBe(CLAUDE_SDK_VERSION);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("安装失败(fake npm exit 1)→ error + 目录清理 + reject", async () => {
|
|
215
|
+
const dir = makeTempDir();
|
|
216
|
+
const npmCommand = makeFakeNpmCommand(makeTempDir());
|
|
217
|
+
const phases: string[] = [];
|
|
218
|
+
process.env.FAKE_NPM_FAIL = "1";
|
|
219
|
+
try {
|
|
220
|
+
await expect(
|
|
221
|
+
installClaudeSdk({
|
|
222
|
+
dir,
|
|
223
|
+
npmCommand,
|
|
224
|
+
onProgress: (p) => phases.push(p.phase),
|
|
225
|
+
}).then(() => {
|
|
226
|
+
throw new Error("should have rejected");
|
|
227
|
+
}),
|
|
228
|
+
).rejects.toThrow(/退出码 1/);
|
|
229
|
+
} finally {
|
|
230
|
+
delete process.env.FAKE_NPM_FAIL;
|
|
231
|
+
}
|
|
232
|
+
expect(phases[phases.length - 1]).toBe("error");
|
|
233
|
+
expect(existsSync(dir)).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("安装失败(spawn error)→ reject 且不残留锁", async () => {
|
|
237
|
+
const dir = makeTempDir();
|
|
238
|
+
await expect(
|
|
239
|
+
installClaudeSdk({ dir, npmCommand: join(makeTempDir(), "no-such-npm") }),
|
|
240
|
+
).rejects.toThrow();
|
|
241
|
+
expect(existsSync(dir)).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("锁存在(活 pid)→ reject 且不启动安装", async () => {
|
|
245
|
+
const dir = makeTempDir();
|
|
246
|
+
writeFileSync(join(dir, ".installing"), String(process.pid), "utf8");
|
|
247
|
+
await expect(
|
|
248
|
+
installClaudeSdk({ dir, npmCommand: "definitely-not-exists" }),
|
|
249
|
+
).rejects.toThrow(/另一个安装任务/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("并发:安装进行中再次调用 → reject", async () => {
|
|
253
|
+
const dir = makeTempDir();
|
|
254
|
+
const npmCommand = makeFakeNpmCommand(makeTempDir());
|
|
255
|
+
process.env.FAKE_NPM_SLEEP = "400";
|
|
256
|
+
try {
|
|
257
|
+
const p1 = installClaudeSdk({
|
|
258
|
+
dir,
|
|
259
|
+
npmCommand,
|
|
260
|
+
expectedVersion: CLAUDE_SDK_VERSION,
|
|
261
|
+
});
|
|
262
|
+
await sleep(80); // 等 installInProgress = true
|
|
263
|
+
await expect(
|
|
264
|
+
installClaudeSdk({ dir, npmCommand }),
|
|
265
|
+
).rejects.toThrow(/正在安装/);
|
|
266
|
+
await p1;
|
|
267
|
+
} finally {
|
|
268
|
+
delete process.env.FAKE_NPM_SLEEP;
|
|
269
|
+
}
|
|
270
|
+
expect(getClaudeSdkInstalledVersion(dir)).toBe(CLAUDE_SDK_VERSION);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("进度 percent 单调且封顶 100", async () => {
|
|
274
|
+
const dir = makeTempDir();
|
|
275
|
+
const npmCommand = makeFakeNpmCommand(makeTempDir());
|
|
276
|
+
const progress: SdkInstallProgress[] = [];
|
|
277
|
+
await installClaudeSdk({ dir, npmCommand, onProgress: (p) => progress.push(p) });
|
|
278
|
+
expect(progress.length).toBeGreaterThan(1);
|
|
279
|
+
for (const p of progress) {
|
|
280
|
+
expect(p.percent).toBeGreaterThanOrEqual(0);
|
|
281
|
+
expect(p.percent).toBeLessThanOrEqual(100);
|
|
282
|
+
}
|
|
283
|
+
expect(progress[progress.length - 1].percent).toBe(100);
|
|
284
|
+
});
|
|
285
|
+
});
|