u1s1-cli-cn 1.18.0 → 1.20.0
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/dist/agents/agent-config.d.ts +1 -1
- package/dist/agents/agent-config.js +1 -1
- package/dist/agents/claude.d.ts +4 -1
- package/dist/agents/claude.js +6 -3
- package/dist/agents/codex.d.ts +4 -1
- package/dist/agents/codex.js +6 -3
- package/dist/agents/custom-models.d.ts +61 -0
- package/dist/agents/custom-models.js +175 -0
- package/dist/agents/dsh.d.ts +90 -0
- package/dist/agents/dsh.js +243 -0
- package/dist/agents/launch.d.ts +24 -2
- package/dist/agents/launch.js +115 -15
- package/dist/agents/models-list.d.ts +65 -0
- package/dist/agents/models-list.js +297 -0
- package/dist/agents/node-runtime.d.ts +91 -0
- package/dist/agents/node-runtime.js +223 -0
- package/dist/agents/runtime.d.ts +26 -3
- package/dist/agents/runtime.js +50 -14
- package/dist/agents/status.d.ts +4 -0
- package/dist/agents/status.js +32 -7
- package/dist/api.d.ts +2 -0
- package/dist/chat-protocol/anthropic.d.ts +29 -0
- package/dist/chat-protocol/anthropic.js +484 -0
- package/dist/chat-protocol/json.d.ts +20 -0
- package/dist/chat-protocol/json.js +22 -0
- package/dist/chat-protocol/responses.d.ts +97 -0
- package/dist/chat-protocol/responses.js +1076 -0
- package/dist/chat-protocol/sse.d.ts +19 -0
- package/dist/chat-protocol/sse.js +67 -0
- package/dist/chat-protocol/thinking.d.ts +22 -0
- package/dist/chat-protocol/thinking.js +58 -0
- package/dist/config-command.d.ts +1 -1
- package/dist/config-command.js +20 -10
- package/dist/config.d.ts +23 -1
- package/dist/config.js +52 -1
- package/dist/custom-endpoint-proxy.d.ts +92 -0
- package/dist/custom-endpoint-proxy.js +461 -0
- package/dist/device-auth.d.ts +14 -3
- package/dist/device-auth.js +36 -2
- package/dist/index.js +12 -3
- package/dist/launcher.d.ts +5 -1
- package/dist/launcher.js +35 -11
- package/package.json +1 -1
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `u1s1 agents models [--json] [--offline]`:给外部前端(Hearth 等)列出每个客户端能用的模型,
|
|
3
|
+
* 带显示名;`id` 就是该客户端 `--model` / `-m` 要的值。
|
|
4
|
+
*
|
|
5
|
+
* 契约(docs/custom-endpoints-all-clients-plan-2026-11-13.md §3):
|
|
6
|
+
* { loggedIn, clients: { pi?: ClientModels, claude?: ClientModels, codex?: ClientModels } }
|
|
7
|
+
* - 未登录:{ loggedIn: false, clients: {} },退出码 0;绝不发起设备登录、不开浏览器、不联网。
|
|
8
|
+
* - 输出只含 id / name / source / endpoint / default / enabled,不带任何密钥、地址、token。
|
|
9
|
+
* - claude / codex 没有本站的 agent-config 缓存时不出现在 clients 里(调用方按「不知道」处理)。
|
|
10
|
+
*
|
|
11
|
+
* 数据来源(都按当前站点隔离):
|
|
12
|
+
* pi 官方模型:/v1/models(短超时)→ <agentDir>/models.json 里上次写的 u1s1 provider → 内置兜底列表;
|
|
13
|
+
* 自定义端点:/v1/endpoints(短超时,成功时刷新 endpoints.json,与启动 Pi 同一做法)→ endpoints.json。
|
|
14
|
+
* claude / codex:只读 ~/.u1s1/agents/<agent>/agent-config.json(拉新配置要起签名代理、拿客户端证明,
|
|
15
|
+
* 不适合做成秒回的列表命令;启动对应客户端时会刷新缓存)。
|
|
16
|
+
* --offline:只读缓存,不联网、不写任何文件。
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { agentDir, apiEndpointToCustom, apiModelToDef, ENDPOINT_ID_RE, loadConfig, MODELS, PROVIDER_ID, readRawConfigFile, resolvePreferredModel, saveEndpointsCache, setCustomEndpoints, setModelsFromApi, u1s1Dir, VERSION, } from "../config.js";
|
|
21
|
+
import { authorizedFetch, hasDeviceCredential } from "../device-auth.js";
|
|
22
|
+
import { tr } from "../i18n.js";
|
|
23
|
+
import { parseAgentConfig, readConfigCache } from "./agent-config.js";
|
|
24
|
+
import { customModelId, routableEndpoint } from "./custom-models.js";
|
|
25
|
+
import { chooseAgentModel, readAgentPrefs } from "./prefs.js";
|
|
26
|
+
const GATEWAY_AGENT = {
|
|
27
|
+
claude: "claude-code",
|
|
28
|
+
codex: "codex",
|
|
29
|
+
};
|
|
30
|
+
// ---------------------------------------------------------------- pure builders
|
|
31
|
+
/** Pi 的 --model 写法:provider/model(和 `u1s1 pi --list-models` 两列拼起来一致)。 */
|
|
32
|
+
export function piModelId(provider, modelId) {
|
|
33
|
+
return `${provider}/${modelId}`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 自定义端点的模型 → 列表条目。idFor 决定该客户端认的 id:Pi 是 provider/model;
|
|
37
|
+
* Claude Code / Codex 接上本地路由后(A2)按各自的写法传进来。
|
|
38
|
+
*/
|
|
39
|
+
export function customModelEntries(endpoints, idFor) {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const ep of endpoints) {
|
|
42
|
+
for (const m of ep.models) {
|
|
43
|
+
out.push({ id: idFor(ep, m), name: m.name || m.id, source: "custom", endpoint: ep.name });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
export function piClientModels(official, endpoints, preferred) {
|
|
49
|
+
return {
|
|
50
|
+
default: piModelId(preferred.provider, preferred.id),
|
|
51
|
+
models: [
|
|
52
|
+
...official.map((m) => ({ id: piModelId(PROVIDER_ID, m.id), name: m.name || m.id, source: "u1s1" })),
|
|
53
|
+
...customModelEntries(endpoints, (ep, m) => piModelId(ep.id, m.id)),
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Claude Code / Codex:网关阵容(agent-config 缓存)+ 自定义端点的模型(只收本地路由转得了的
|
|
59
|
+
* openai-completions 端点,id = ep…/<模型>,见 custom-models.ts)。default 是启动时真正会用的模型:
|
|
60
|
+
* 用户在 u1s1 config 里选的(网关模型或自定义模型)还在就是它,否则网关默认——与 launch.ts 同一判断。
|
|
61
|
+
*/
|
|
62
|
+
export function gatewayClientModels(config, preferredModel, endpoints = []) {
|
|
63
|
+
const custom = customModelEntries(endpoints.filter(routableEndpoint), (ep, m) => customModelId(ep.id, m.id));
|
|
64
|
+
const lineup = { ...config, models: [...config.models, ...custom.map((m) => ({ id: m.id, name: m.name }))] };
|
|
65
|
+
return {
|
|
66
|
+
default: chooseAgentModel(lineup, preferredModel).model,
|
|
67
|
+
enabled: config.enabled,
|
|
68
|
+
models: [
|
|
69
|
+
...config.models.map((m) => ({ id: m.id, name: m.name || m.id, source: "u1s1" })),
|
|
70
|
+
...custom,
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------- caches (read-only)
|
|
75
|
+
function readJson(path) {
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function isRecord(v) {
|
|
84
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
85
|
+
}
|
|
86
|
+
/** 与 config.ts 的端点缓存同一个文件(这里只读) */
|
|
87
|
+
function endpointsCacheFile() {
|
|
88
|
+
return join(u1s1Dir, "endpoints.json");
|
|
89
|
+
}
|
|
90
|
+
/** Pi 启动时把 u1s1 provider 的 baseUrl 写成本机签名代理(127.0.0.1 的随机端口)或站点地址。 */
|
|
91
|
+
function sameSiteProviderBaseUrl(value, baseUrl) {
|
|
92
|
+
if (typeof value !== "string")
|
|
93
|
+
return false;
|
|
94
|
+
if (value === baseUrl)
|
|
95
|
+
return true;
|
|
96
|
+
try {
|
|
97
|
+
const host = new URL(value).hostname;
|
|
98
|
+
return host === "127.0.0.1" || host === "localhost" || host === "[::1]";
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 上次启动 Pi 时写进 <agentDir>/models.json 的 u1s1 provider。那里的 baseUrl 通常是本机签名代理,
|
|
106
|
+
* 认不出站点;只排除明确指向别的站点的条目(换站后第一次启动 Pi 之前,离线时可能列出上一站的名字)。
|
|
107
|
+
* 那里的 name 拼了「 · 提示」(见 toProviderModels),这里去掉,只留显示名。
|
|
108
|
+
*/
|
|
109
|
+
export function officialModelsFromProviderFile(path, baseUrl) {
|
|
110
|
+
const root = readJson(path);
|
|
111
|
+
const providers = isRecord(root) ? root["providers"] : undefined;
|
|
112
|
+
const entry = isRecord(providers) ? providers[PROVIDER_ID] : undefined;
|
|
113
|
+
if (!isRecord(entry) || !sameSiteProviderBaseUrl(entry["baseUrl"], baseUrl) || !Array.isArray(entry["models"]))
|
|
114
|
+
return undefined;
|
|
115
|
+
const models = [];
|
|
116
|
+
for (const m of entry["models"]) {
|
|
117
|
+
if (!isRecord(m) || typeof m["id"] !== "string" || !m["id"])
|
|
118
|
+
continue;
|
|
119
|
+
const raw = typeof m["name"] === "string" ? m["name"] : "";
|
|
120
|
+
const sep = raw.indexOf(" · ");
|
|
121
|
+
const name = (sep > 0 ? raw.slice(0, sep) : raw).trim() || m["id"];
|
|
122
|
+
models.push({
|
|
123
|
+
id: m["id"],
|
|
124
|
+
name,
|
|
125
|
+
aliases: [],
|
|
126
|
+
reasoning: m["reasoning"] === true,
|
|
127
|
+
vision: false,
|
|
128
|
+
contextWindow: 0,
|
|
129
|
+
maxTokens: 0,
|
|
130
|
+
cost: null,
|
|
131
|
+
note: "",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return models.length ? models : undefined;
|
|
135
|
+
}
|
|
136
|
+
/** endpoints.json 只取列表要用的字段,形状不对的条目跳过。 */
|
|
137
|
+
export function endpointsFromCache(raw) {
|
|
138
|
+
if (!Array.isArray(raw))
|
|
139
|
+
return [];
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const e of raw) {
|
|
142
|
+
if (!isRecord(e) || typeof e["id"] !== "string" || !ENDPOINT_ID_RE.test(e["id"]) || !Array.isArray(e["models"]))
|
|
143
|
+
continue;
|
|
144
|
+
const models = [];
|
|
145
|
+
for (const m of e["models"]) {
|
|
146
|
+
if (!isRecord(m) || typeof m["id"] !== "string" || !m["id"])
|
|
147
|
+
continue;
|
|
148
|
+
models.push({
|
|
149
|
+
id: m["id"],
|
|
150
|
+
name: typeof m["name"] === "string" && m["name"] ? m["name"] : m["id"],
|
|
151
|
+
aliases: [],
|
|
152
|
+
reasoning: m["reasoning"] === true,
|
|
153
|
+
vision: m["vision"] === true,
|
|
154
|
+
contextWindow: 0,
|
|
155
|
+
maxTokens: 0,
|
|
156
|
+
cost: null,
|
|
157
|
+
note: "",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// api 不是密钥,留着让 Claude Code / Codex 的列表只收本地路由转得了的端点;baseUrl / apiKey 不读
|
|
161
|
+
const api = typeof e["api"] === "string" ? e["api"] : "";
|
|
162
|
+
out.push({ id: e["id"], name: typeof e["name"] === "string" ? e["name"] : e["id"], baseUrl: "", api, models });
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
export function cachedAgentConfig(cacheFile, site, agent) {
|
|
167
|
+
const cached = readConfigCache(cacheFile, site);
|
|
168
|
+
return cached ? parseAgentConfig(cached.body, agent) : undefined;
|
|
169
|
+
}
|
|
170
|
+
export const LIVE_REFRESH_TIMEOUT_MS = 2_500;
|
|
171
|
+
/**
|
|
172
|
+
* 并行拉 /v1/models 与 /v1/endpoints,整体不超过 timeoutMs;到点一起中止,
|
|
173
|
+
* 任何失败(断网、401、5xx、格式不对)都只是让对应字段缺席,调用方退回缓存。
|
|
174
|
+
*/
|
|
175
|
+
export async function fetchLiveCatalog(get, timeoutMs = LIVE_REFRESH_TIMEOUT_MS) {
|
|
176
|
+
const ctrl = new AbortController();
|
|
177
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
178
|
+
const json = async (path) => {
|
|
179
|
+
try {
|
|
180
|
+
const res = await get(path, ctrl.signal);
|
|
181
|
+
if (!res.ok)
|
|
182
|
+
return undefined;
|
|
183
|
+
const text = await res.text();
|
|
184
|
+
if (text.length > 2 * 1024 * 1024)
|
|
185
|
+
return undefined;
|
|
186
|
+
const body = JSON.parse(text);
|
|
187
|
+
return isRecord(body) ? body : undefined;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
try {
|
|
194
|
+
const [models, endpoints] = await Promise.all([json("/models"), json("/endpoints")]);
|
|
195
|
+
return {
|
|
196
|
+
...(Array.isArray(models?.["data"]) && models["data"].length ? { models: models["data"] } : {}),
|
|
197
|
+
...(Array.isArray(endpoints?.["endpoints"]) ? { endpoints: endpoints["endpoints"] } : {}),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
clearTimeout(timer);
|
|
202
|
+
ctrl.abort();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
export async function buildModelsListing(deps = {}) {
|
|
206
|
+
const cfg = loadConfig();
|
|
207
|
+
// 未登录:到此为止。不联网、不触发设备登录
|
|
208
|
+
if (!hasDeviceCredential(cfg))
|
|
209
|
+
return { loggedIn: false, clients: {} };
|
|
210
|
+
let live = {};
|
|
211
|
+
if (!deps.offline) {
|
|
212
|
+
const fetchLive = deps.fetchLive ?? (() => fetchLiveCatalog((path, signal) => authorizedFetch(cfg, `${cfg.baseUrl}${path}`, { headers: { "x-u1s1-version": VERSION }, signal })));
|
|
213
|
+
live = await fetchLive().catch(() => ({}));
|
|
214
|
+
}
|
|
215
|
+
// 官方模型
|
|
216
|
+
if (live.models) {
|
|
217
|
+
setModelsFromApi(live.models.map(apiModelToDef));
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
const cached = officialModelsFromProviderFile(join(agentDir, "models.json"), cfg.baseUrl);
|
|
221
|
+
if (cached)
|
|
222
|
+
setModelsFromApi(cached);
|
|
223
|
+
}
|
|
224
|
+
// 自定义端点(成功拉到时刷新缓存,与启动 Pi 的 loadCustomEndpoints 同一做法;--offline 不写)
|
|
225
|
+
let endpoints;
|
|
226
|
+
if (live.endpoints) {
|
|
227
|
+
try {
|
|
228
|
+
endpoints = live.endpoints.map(apiEndpointToCustom);
|
|
229
|
+
if (!deps.offline) {
|
|
230
|
+
try {
|
|
231
|
+
saveEndpointsCache(endpoints);
|
|
232
|
+
}
|
|
233
|
+
catch { /* 缓存写不进去不影响列表 */ }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
endpoints = endpointsFromCache(readJson(endpointsCacheFile()));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
endpoints = endpointsFromCache(readJson(endpointsCacheFile()));
|
|
242
|
+
}
|
|
243
|
+
setCustomEndpoints(endpoints);
|
|
244
|
+
const clients = {
|
|
245
|
+
pi: piClientModels(MODELS, endpoints, resolvePreferredModel(cfg)),
|
|
246
|
+
};
|
|
247
|
+
const site = new URL(cfg.baseUrl).origin;
|
|
248
|
+
const raw = readRawConfigFile();
|
|
249
|
+
for (const client of ["claude", "codex"]) {
|
|
250
|
+
const config = cachedAgentConfig(join(u1s1Dir, "agents", client, "agent-config.json"), site, GATEWAY_AGENT[client]);
|
|
251
|
+
if (config)
|
|
252
|
+
clients[client] = gatewayClientModels(config, readAgentPrefs(client, raw).model, endpoints);
|
|
253
|
+
}
|
|
254
|
+
return { loggedIn: true, clients };
|
|
255
|
+
}
|
|
256
|
+
/** 人看的表。 */
|
|
257
|
+
export function formatModelsTable(listing) {
|
|
258
|
+
if (!listing.loggedIn)
|
|
259
|
+
return [tr("还没有登录,先运行 u1s1 login。", "Not signed in yet. Run u1s1 login first.")];
|
|
260
|
+
const lines = [];
|
|
261
|
+
for (const client of ["pi", "claude", "codex"]) {
|
|
262
|
+
const c = listing.clients[client];
|
|
263
|
+
lines.push("");
|
|
264
|
+
if (!c) {
|
|
265
|
+
lines.push(`${client} ${tr("(还没有模型列表,启动一次 u1s1 " + client + " 后可见)", "(no model list yet; start u1s1 " + client + " once to load it)")}`);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
lines.push(c.enabled === false ? `${client} ${tr("(暂未开放)", "(not available)")}` : client);
|
|
269
|
+
const width = Math.max(0, ...c.models.map((m) => m.id.length));
|
|
270
|
+
for (const m of c.models) {
|
|
271
|
+
const mark = m.id === c.default ? "●" : " ";
|
|
272
|
+
const tag = m.source === "custom" ? ` [${m.endpoint ?? tr("自定义", "custom")}]` : "";
|
|
273
|
+
lines.push(` ${mark} ${m.id.padEnd(width)} ${m.name}${tag}`);
|
|
274
|
+
}
|
|
275
|
+
if (c.models.length === 0)
|
|
276
|
+
lines.push(` ${tr("(没有可用模型)", "(no models)")}`);
|
|
277
|
+
}
|
|
278
|
+
lines.push("");
|
|
279
|
+
lines.push(tr("● = 默认模型。启动时用 --model 传左列的 id。", "● = default model. Pass the left-hand id with --model."));
|
|
280
|
+
return lines;
|
|
281
|
+
}
|
|
282
|
+
export async function modelsCommand(args, deps = {}) {
|
|
283
|
+
const json = args.includes("--json");
|
|
284
|
+
const offline = deps.offline ?? args.includes("--offline");
|
|
285
|
+
const unknown = args.filter((a) => a !== "--json" && a !== "--offline");
|
|
286
|
+
if (unknown.length) {
|
|
287
|
+
console.error(tr("用法: u1s1 agents models [--json] [--offline]", "Usage: u1s1 agents models [--json] [--offline]"));
|
|
288
|
+
return 1;
|
|
289
|
+
}
|
|
290
|
+
const listing = await buildModelsListing({ ...deps, offline });
|
|
291
|
+
if (json)
|
|
292
|
+
process.stdout.write(JSON.stringify(listing) + "\n");
|
|
293
|
+
else
|
|
294
|
+
for (const line of formatModelsTable(listing))
|
|
295
|
+
console.log(line);
|
|
296
|
+
return 0;
|
|
297
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { SiteId } from "../site.js";
|
|
2
|
+
export declare const DSH_MIN_NODE = "24.9.0";
|
|
3
|
+
/** 钉死的 Node 24 LTS(2026-11-10 发布的安全更新)。 */
|
|
4
|
+
export declare const NODE24_VERSION = "24.22.3";
|
|
5
|
+
/** node-v24.22.3 官方 SHASUMS256.txt(https://nodejs.org/dist/v24.22.3/SHASUMS256.txt)里对应的条目。 */
|
|
6
|
+
export declare const NODE24_SHA256: Readonly<Record<string, string>>;
|
|
7
|
+
export declare const NODE_BIN_ENV = "U1S1_DSH_NODE";
|
|
8
|
+
export interface NodeArchive {
|
|
9
|
+
/** nodejs.org 的平台名(darwin-arm64、win-x64…) */
|
|
10
|
+
target: string;
|
|
11
|
+
/** 压缩包文件名 */
|
|
12
|
+
file: string;
|
|
13
|
+
/** 解压后的顶层目录 */
|
|
14
|
+
dir: string;
|
|
15
|
+
/** node 可执行文件在顶层目录下的位置(posix 写法) */
|
|
16
|
+
binInDir: string;
|
|
17
|
+
format: "tar.gz" | "zip";
|
|
18
|
+
sha256: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function nodeArchive(platform?: NodeJS.Platform, arch?: string, version?: string): NodeArchive | undefined;
|
|
21
|
+
/** 国际站走官方 nodejs.org;中国站走 npmmirror 的 Node 镜像(同一份文件,按官方 SHA-256 校验)。U1S1_NODE_MIRROR 可覆盖。 */
|
|
22
|
+
export declare function nodeDistBase(site: SiteId, env?: NodeJS.ProcessEnv): string;
|
|
23
|
+
export declare function nodeDownloadUrl(site: SiteId, archive: NodeArchive, version?: string, env?: NodeJS.ProcessEnv): string;
|
|
24
|
+
/** ~/.u1s1/agents/node/<版本>/ 下的 node(解压后只留可执行文件和 LICENSE)。 */
|
|
25
|
+
export declare function managedNodeBin(agentsRoot: string, version?: string, platform?: NodeJS.Platform): string;
|
|
26
|
+
/** `<node> --version` → 版本号;跑不起来返回 undefined。 */
|
|
27
|
+
export declare function probeNodeVersion(bin: string): string | undefined;
|
|
28
|
+
export declare function nodeVersionOk(version: string | undefined, min?: string): boolean;
|
|
29
|
+
export type NodeSource = "env" | "self" | "path" | "managed" | "download";
|
|
30
|
+
export interface FoundNode {
|
|
31
|
+
bin: string;
|
|
32
|
+
version: string;
|
|
33
|
+
source: NodeSource;
|
|
34
|
+
}
|
|
35
|
+
export declare class NodeRuntimeError extends Error {
|
|
36
|
+
readonly code: "env_unusable" | "declined" | "unsupported_platform" | "download_failed" | "verify_failed";
|
|
37
|
+
constructor(code: "env_unusable" | "declined" | "unsupported_platform" | "download_failed" | "verify_failed", message: string);
|
|
38
|
+
}
|
|
39
|
+
export interface FindNodeDeps {
|
|
40
|
+
env?: NodeJS.ProcessEnv;
|
|
41
|
+
execPath?: string;
|
|
42
|
+
selfVersion?: string;
|
|
43
|
+
platform?: NodeJS.Platform;
|
|
44
|
+
probe?: (bin: string) => string | undefined;
|
|
45
|
+
exists?: (p: string) => boolean;
|
|
46
|
+
}
|
|
47
|
+
/** 不下载:按 1-4 的顺序找够新的 node。U1S1_DSH_NODE 指定了但不能用时抛 env_unusable。 */
|
|
48
|
+
export declare function findNode(agentsRoot: string, deps?: FindNodeDeps): FoundNode | undefined;
|
|
49
|
+
export interface NodeDownloadInfo {
|
|
50
|
+
version: string;
|
|
51
|
+
url: string;
|
|
52
|
+
host: string;
|
|
53
|
+
downloadBytes?: number;
|
|
54
|
+
}
|
|
55
|
+
export interface EnsureNodeOptions {
|
|
56
|
+
agentsRoot: string;
|
|
57
|
+
site: SiteId;
|
|
58
|
+
/** 下载前征得同意;返回 false = 拒绝 */
|
|
59
|
+
consent: (info: NodeDownloadInfo) => Promise<boolean>;
|
|
60
|
+
log: (line: string) => void;
|
|
61
|
+
text: {
|
|
62
|
+
declined: () => string;
|
|
63
|
+
unsupported: () => string;
|
|
64
|
+
downloadFailed: (detail: string) => string;
|
|
65
|
+
verifyFailed: (detail: string) => string;
|
|
66
|
+
downloading: (version: string) => string;
|
|
67
|
+
installed: (version: string) => string;
|
|
68
|
+
};
|
|
69
|
+
env?: NodeJS.ProcessEnv;
|
|
70
|
+
fetchImpl?: typeof fetch;
|
|
71
|
+
/** 测试注入 */
|
|
72
|
+
find?: () => FoundNode | undefined;
|
|
73
|
+
}
|
|
74
|
+
/** 找到够新的 node;没有就征得同意后下载钉死的 Node 24 LTS。 */
|
|
75
|
+
export declare function ensureNode(opts: EnsureNodeOptions): Promise<FoundNode>;
|
|
76
|
+
/** 各平台压缩包约 30-55 MB;提示用。 */
|
|
77
|
+
export declare const NODE24_APPROX_BYTES: number;
|
|
78
|
+
/**
|
|
79
|
+
* 下载 → 按钉死的 SHA-256 校验 → 只解出 node 和 LICENSE → 确认能跑且版本对 → 改名成正式目录。
|
|
80
|
+
* 中途失败只留暂存目录的残渣(下次启动前清掉),不影响已有的东西。
|
|
81
|
+
*/
|
|
82
|
+
export declare function downloadNode(input: {
|
|
83
|
+
agentsRoot: string;
|
|
84
|
+
archive: NodeArchive;
|
|
85
|
+
url: string;
|
|
86
|
+
fetchImpl: typeof fetch;
|
|
87
|
+
text: EnsureNodeOptions["text"];
|
|
88
|
+
version?: string;
|
|
89
|
+
}): Promise<string>;
|
|
90
|
+
/** 暂存目录残渣(下载中途被打断)。 */
|
|
91
|
+
export declare function cleanNodeStaging(agentsRoot: string): void;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh(DeepSeek Harness)要的 Node(engines.node >=24.9.0)。便携包只带 Node 22,所以:
|
|
3
|
+
*
|
|
4
|
+
* 1. U1S1_DSH_NODE 指定的 node(排查/测试用;版本不够直接报错,不悄悄换别的)
|
|
5
|
+
* 2. 运行本 CLI 的 Node 就够新(用户自己 npm 装的 u1s1 跑在 Node 24+ 上)
|
|
6
|
+
* 3. PATH 里的 node 够新
|
|
7
|
+
* 4. 已下载到 ~/.u1s1/agents/node/<版本>/ 的官方 Node 24 LTS
|
|
8
|
+
* 5. 都没有:征得同意后从 nodejs.org(中国站走 npmmirror 的 Node 镜像)下载钉死的
|
|
9
|
+
* Node 24 LTS 压缩包,按代码里的 SHA-256(抄自该版本官方 SHASUMS256.txt)校验后解出
|
|
10
|
+
* node 可执行文件和 LICENSE。
|
|
11
|
+
*
|
|
12
|
+
* 只给 dsh 用:不改 PATH、不碰便携包自带的 Node,也不装 npm(npm 仍用 CLI 自带的那份,
|
|
13
|
+
* 只是改由这个 Node 运行,见 runtime.ts installRuntime)。
|
|
14
|
+
*/
|
|
15
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
16
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
17
|
+
import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
|
|
18
|
+
import { posix, win32 } from "node:path";
|
|
19
|
+
import { pipeline } from "node:stream/promises";
|
|
20
|
+
import { Readable } from "node:stream";
|
|
21
|
+
import { stripSecretEnv } from "../secret-env.js";
|
|
22
|
+
import { compareVersions, findOnPath, normalizeVersion, versionFromOutput } from "./runtime.js";
|
|
23
|
+
export const DSH_MIN_NODE = "24.9.0";
|
|
24
|
+
/** 钉死的 Node 24 LTS(2026-11-10 发布的安全更新)。 */
|
|
25
|
+
export const NODE24_VERSION = "24.22.3";
|
|
26
|
+
/** node-v24.22.3 官方 SHASUMS256.txt(https://nodejs.org/dist/v24.22.3/SHASUMS256.txt)里对应的条目。 */
|
|
27
|
+
export const NODE24_SHA256 = {
|
|
28
|
+
"darwin-arm64": "ff6e17d4874b76d272212b908cc020d6e815fba5a6c7eaaa06add725840307fd",
|
|
29
|
+
"darwin-x64": "977614af0504c81045e7fd85596dbca0e5f951b96681516dadafedcca8c447f7",
|
|
30
|
+
"linux-arm64": "261af2af250dfd7e9f25cf151d1b2713a0adf8f10a9ad116c4947f68627affe8",
|
|
31
|
+
"linux-x64": "061ec3e3062115705b56b51fa9cc330ab50d34b6969387d4e4b1a50c6809acc2",
|
|
32
|
+
"win-arm64": "b3cc74a295b39d4f273271e96f58c06dd2b35757ca12abec64c7d0598b434f14",
|
|
33
|
+
"win-x64": "e612af99f626ab415ea225af6bce5c971feb448cb5cae250bb1c3fa50c5f7d9f",
|
|
34
|
+
};
|
|
35
|
+
export const NODE_BIN_ENV = "U1S1_DSH_NODE";
|
|
36
|
+
export function nodeArchive(platform = process.platform, arch = process.arch, version = NODE24_VERSION) {
|
|
37
|
+
const os = platform === "win32" ? "win" : platform === "darwin" ? "darwin" : platform === "linux" ? "linux" : undefined;
|
|
38
|
+
if (!os || (arch !== "x64" && arch !== "arm64"))
|
|
39
|
+
return undefined;
|
|
40
|
+
const target = `${os}-${arch}`;
|
|
41
|
+
const sha256 = version === NODE24_VERSION ? NODE24_SHA256[target] : undefined;
|
|
42
|
+
if (!sha256)
|
|
43
|
+
return undefined;
|
|
44
|
+
const dir = `node-v${version}-${target}`;
|
|
45
|
+
const format = os === "win" ? "zip" : "tar.gz";
|
|
46
|
+
return { target, file: `${dir}.${format}`, dir, binInDir: os === "win" ? "node.exe" : "bin/node", format, sha256 };
|
|
47
|
+
}
|
|
48
|
+
/** 国际站走官方 nodejs.org;中国站走 npmmirror 的 Node 镜像(同一份文件,按官方 SHA-256 校验)。U1S1_NODE_MIRROR 可覆盖。 */
|
|
49
|
+
export function nodeDistBase(site, env = process.env) {
|
|
50
|
+
const override = env["U1S1_NODE_MIRROR"]?.trim();
|
|
51
|
+
if (override && /^https?:\/\//.test(override))
|
|
52
|
+
return override.replace(/\/+$/, "");
|
|
53
|
+
return site === "cn" ? "https://cdn.npmmirror.com/binaries/node" : "https://nodejs.org/dist";
|
|
54
|
+
}
|
|
55
|
+
export function nodeDownloadUrl(site, archive, version = NODE24_VERSION, env = process.env) {
|
|
56
|
+
return `${nodeDistBase(site, env)}/v${version}/${archive.file}`;
|
|
57
|
+
}
|
|
58
|
+
function join(...parts) {
|
|
59
|
+
return (process.platform === "win32" ? win32 : posix).join(...parts);
|
|
60
|
+
}
|
|
61
|
+
/** ~/.u1s1/agents/node/<版本>/ 下的 node(解压后只留可执行文件和 LICENSE)。 */
|
|
62
|
+
export function managedNodeBin(agentsRoot, version = NODE24_VERSION, platform = process.platform) {
|
|
63
|
+
return join(agentsRoot, "node", version, platform === "win32" ? "node.exe" : "node");
|
|
64
|
+
}
|
|
65
|
+
/** `<node> --version` → 版本号;跑不起来返回 undefined。 */
|
|
66
|
+
export function probeNodeVersion(bin) {
|
|
67
|
+
try {
|
|
68
|
+
const r = spawnSync(bin, ["--version"], { env: stripSecretEnv(process.env), encoding: "utf8", timeout: 15_000, windowsHide: true });
|
|
69
|
+
return r.status === 0 ? versionFromOutput(r.stdout ?? "") : undefined;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function nodeVersionOk(version, min = DSH_MIN_NODE) {
|
|
76
|
+
const v = normalizeVersion(version);
|
|
77
|
+
return !!v && compareVersions(v, min) >= 0;
|
|
78
|
+
}
|
|
79
|
+
export class NodeRuntimeError extends Error {
|
|
80
|
+
code;
|
|
81
|
+
constructor(code, message) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.code = code;
|
|
84
|
+
this.name = "NodeRuntimeError";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** 不下载:按 1-4 的顺序找够新的 node。U1S1_DSH_NODE 指定了但不能用时抛 env_unusable。 */
|
|
88
|
+
export function findNode(agentsRoot, deps = {}) {
|
|
89
|
+
const env = deps.env ?? process.env;
|
|
90
|
+
const probe = deps.probe ?? probeNodeVersion;
|
|
91
|
+
const exists = deps.exists ?? existsSync;
|
|
92
|
+
const pinned = env[NODE_BIN_ENV]?.trim();
|
|
93
|
+
if (pinned) {
|
|
94
|
+
const v = probe(pinned);
|
|
95
|
+
if (!v || !nodeVersionOk(v)) {
|
|
96
|
+
throw new NodeRuntimeError("env_unusable", `${NODE_BIN_ENV}=${pinned}: ${v ? `Node ${v} < ${DSH_MIN_NODE}` : "not runnable"}`);
|
|
97
|
+
}
|
|
98
|
+
return { bin: pinned, version: v, source: "env" };
|
|
99
|
+
}
|
|
100
|
+
const selfVersion = deps.selfVersion ?? process.versions.node;
|
|
101
|
+
if (nodeVersionOk(selfVersion))
|
|
102
|
+
return { bin: deps.execPath ?? process.execPath, version: normalizeVersion(selfVersion), source: "self" };
|
|
103
|
+
const onPath = findOnPath("node", { env, platform: deps.platform ?? process.platform, exists });
|
|
104
|
+
if (onPath) {
|
|
105
|
+
const v = probe(onPath);
|
|
106
|
+
if (v && nodeVersionOk(v))
|
|
107
|
+
return { bin: onPath, version: v, source: "path" };
|
|
108
|
+
}
|
|
109
|
+
const managed = managedNodeBin(agentsRoot, NODE24_VERSION, deps.platform ?? process.platform);
|
|
110
|
+
if (exists(managed)) {
|
|
111
|
+
const v = probe(managed);
|
|
112
|
+
if (v && nodeVersionOk(v))
|
|
113
|
+
return { bin: managed, version: v, source: "managed" };
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
/** 找到够新的 node;没有就征得同意后下载钉死的 Node 24 LTS。 */
|
|
118
|
+
export async function ensureNode(opts) {
|
|
119
|
+
const found = (opts.find ?? (() => findNode(opts.agentsRoot, { env: opts.env })))();
|
|
120
|
+
if (found)
|
|
121
|
+
return found;
|
|
122
|
+
const archive = nodeArchive();
|
|
123
|
+
if (!archive)
|
|
124
|
+
throw new NodeRuntimeError("unsupported_platform", opts.text.unsupported());
|
|
125
|
+
const url = nodeDownloadUrl(opts.site, archive, NODE24_VERSION, opts.env);
|
|
126
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
127
|
+
const ok = await opts.consent({ version: NODE24_VERSION, url, host: new URL(url).host, downloadBytes: NODE24_APPROX_BYTES });
|
|
128
|
+
if (!ok)
|
|
129
|
+
throw new NodeRuntimeError("declined", opts.text.declined());
|
|
130
|
+
opts.log(opts.text.downloading(NODE24_VERSION));
|
|
131
|
+
const bin = await downloadNode({ agentsRoot: opts.agentsRoot, archive, url, fetchImpl, text: opts.text });
|
|
132
|
+
opts.log(opts.text.installed(NODE24_VERSION));
|
|
133
|
+
return { bin, version: NODE24_VERSION, source: "download" };
|
|
134
|
+
}
|
|
135
|
+
/** 各平台压缩包约 30-55 MB;提示用。 */
|
|
136
|
+
export const NODE24_APPROX_BYTES = 55 * 1024 * 1024;
|
|
137
|
+
async function sha256File(file) {
|
|
138
|
+
const hash = createHash("sha256");
|
|
139
|
+
await pipeline(createReadStream(file), hash);
|
|
140
|
+
return hash.digest("hex");
|
|
141
|
+
}
|
|
142
|
+
function runTar(args, cwd) {
|
|
143
|
+
return new Promise((resolve) => {
|
|
144
|
+
const child = spawn("tar", args, { cwd, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
145
|
+
let output = "";
|
|
146
|
+
child.stdout?.on("data", (c) => { output += c.toString("utf8"); });
|
|
147
|
+
child.stderr?.on("data", (c) => { output += c.toString("utf8"); });
|
|
148
|
+
child.on("error", (e) => resolve({ code: 1, output: output + String(e) }));
|
|
149
|
+
child.on("exit", (code) => resolve({ code: code ?? 1, output }));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* 下载 → 按钉死的 SHA-256 校验 → 只解出 node 和 LICENSE → 确认能跑且版本对 → 改名成正式目录。
|
|
154
|
+
* 中途失败只留暂存目录的残渣(下次启动前清掉),不影响已有的东西。
|
|
155
|
+
*/
|
|
156
|
+
export async function downloadNode(input) {
|
|
157
|
+
const version = input.version ?? NODE24_VERSION;
|
|
158
|
+
const root = join(input.agentsRoot, "node");
|
|
159
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
160
|
+
const staging = join(root, `.staging-${version}-${randomBytes(4).toString("hex")}`);
|
|
161
|
+
mkdirSync(staging, { recursive: true });
|
|
162
|
+
try {
|
|
163
|
+
const archivePath = join(staging, input.archive.file);
|
|
164
|
+
let res;
|
|
165
|
+
try {
|
|
166
|
+
res = await input.fetchImpl(input.url, { signal: AbortSignal.timeout(10 * 60_000) });
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
throw new NodeRuntimeError("download_failed", input.text.downloadFailed(e instanceof Error ? e.message : String(e)));
|
|
170
|
+
}
|
|
171
|
+
if (!res.ok || !res.body)
|
|
172
|
+
throw new NodeRuntimeError("download_failed", input.text.downloadFailed(`HTTP ${res.status}`));
|
|
173
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(archivePath));
|
|
174
|
+
const got = await sha256File(archivePath);
|
|
175
|
+
if (got !== input.archive.sha256) {
|
|
176
|
+
throw new NodeRuntimeError("verify_failed", input.text.verifyFailed(`sha256 ${got} != ${input.archive.sha256}`));
|
|
177
|
+
}
|
|
178
|
+
const members = [`${input.archive.dir}/${input.archive.binInDir}`, `${input.archive.dir}/LICENSE`];
|
|
179
|
+
const extract = await runTar(["-xf", archivePath, ...members], staging);
|
|
180
|
+
if (extract.code !== 0)
|
|
181
|
+
throw new NodeRuntimeError("verify_failed", input.text.verifyFailed(`tar: ${extract.output.trim().slice(-300)}`));
|
|
182
|
+
const extracted = join(staging, input.archive.dir, ...input.archive.binInDir.split("/"));
|
|
183
|
+
const v = probeNodeVersion(extracted);
|
|
184
|
+
if (v !== version)
|
|
185
|
+
throw new NodeRuntimeError("verify_failed", input.text.verifyFailed(`node reports ${v ?? "nothing"}, expected ${version}`));
|
|
186
|
+
const target = join(root, version);
|
|
187
|
+
const finalDir = join(staging, "final");
|
|
188
|
+
mkdirSync(finalDir);
|
|
189
|
+
const binName = process.platform === "win32" ? "node.exe" : "node";
|
|
190
|
+
renameSync(extracted, join(finalDir, binName));
|
|
191
|
+
const license = join(staging, input.archive.dir, "LICENSE");
|
|
192
|
+
if (existsSync(license))
|
|
193
|
+
renameSync(license, join(finalDir, "LICENSE"));
|
|
194
|
+
rmSync(target, { recursive: true, force: true });
|
|
195
|
+
try {
|
|
196
|
+
renameSync(finalDir, target);
|
|
197
|
+
}
|
|
198
|
+
catch (e) {
|
|
199
|
+
// 另一个 u1s1 进程刚装好同一版本:用它的
|
|
200
|
+
if (!existsSync(join(target, binName)))
|
|
201
|
+
throw e;
|
|
202
|
+
}
|
|
203
|
+
return join(target, binName);
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
rmSync(staging, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** 暂存目录残渣(下载中途被打断)。 */
|
|
210
|
+
export function cleanNodeStaging(agentsRoot) {
|
|
211
|
+
const root = join(agentsRoot, "node");
|
|
212
|
+
let names;
|
|
213
|
+
try {
|
|
214
|
+
names = readdirSync(root);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
for (const name of names) {
|
|
220
|
+
if (name.startsWith(".staging-"))
|
|
221
|
+
rmSync(join(root, name), { recursive: true, force: true });
|
|
222
|
+
}
|
|
223
|
+
}
|