u1s1-cli 1.14.6 → 1.15.1
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/agent-setup.d.ts +7 -0
- package/dist/agent-setup.js +14 -0
- package/dist/agents/agent-config.d.ts +83 -0
- package/dist/agents/agent-config.js +154 -0
- package/dist/agents/claude.d.ts +113 -0
- package/dist/agents/claude.js +411 -0
- package/dist/agents/codex.d.ts +111 -0
- package/dist/agents/codex.js +392 -0
- package/dist/agents/launch.d.ts +109 -0
- package/dist/agents/launch.js +444 -0
- package/dist/agents/pi.d.ts +9 -0
- package/dist/agents/pi.js +562 -0
- package/dist/agents/prefs.d.ts +35 -0
- package/dist/agents/prefs.js +96 -0
- package/dist/agents/runtime.d.ts +354 -0
- package/dist/agents/runtime.js +884 -0
- package/dist/agents/status.d.ts +20 -0
- package/dist/agents/status.js +241 -0
- package/dist/config-command.d.ts +18 -0
- package/dist/config-command.js +335 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +35 -1
- package/dist/device-auth.d.ts +37 -1
- package/dist/device-auth.js +43 -8
- package/dist/embed.d.ts +13 -0
- package/dist/embed.js +15 -0
- package/dist/index.js +22 -556
- package/dist/launcher.d.ts +48 -0
- package/dist/launcher.js +235 -0
- package/dist/mcp-server.d.ts +105 -0
- package/dist/mcp-server.js +449 -0
- package/dist/pi-ui-text.d.ts +57 -0
- package/dist/pi-ui-text.js +148 -0
- package/dist/usage.js +2 -2
- package/package.json +1 -1
- package/scripts/p0-claude-code/README.md +39 -0
- package/scripts/p0-claude-code/launch-claude.ts +94 -0
- package/scripts/p0-claude-code/replay.ts +74 -0
- package/scripts/p0-claude-code/seed-local.mjs +145 -0
- package/scripts/p0-claude-code/tap.mjs +105 -0
- package/scripts/patch-pi.js +76 -55
- package/scripts/pi-patch-report.mjs +224 -0
- package/scripts/pi-upgrade.mjs +829 -0
- package/scripts/render-regression.mjs +67 -5
|
@@ -0,0 +1,884 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 第三方客户端运行时:查找、下载、版本、更新、刹车。
|
|
3
|
+
*
|
|
4
|
+
* 合规约束(规划第 6 节):原版只从官方 npm 包下载、装到 ~/.u1s1/agents/<客户端>,
|
|
5
|
+
* 用户确认后才下载;不打进我们的包、不改二进制、不动它自带的登录方式。
|
|
6
|
+
* 安装交给 npm(`npm install --prefix`),上游调整包结构(平台二进制、postinstall)由 npm 处理。
|
|
7
|
+
*
|
|
8
|
+
* 目录布局(以 claude 为例;codex 同理,可执行文件在平台包里,见 CODEX_RUNTIME):
|
|
9
|
+
* ~/.u1s1/agents/claude/runtime/<版本>/node_modules/@anthropic-ai/claude-code/bin/claude.exe
|
|
10
|
+
* ~/.u1s1/agents/claude/state.json 当前版本、pin、每日检查时间、待更新版本
|
|
11
|
+
* ~/.u1s1/agents/claude/config/ CLAUDE_CONFIG_DIR(见 claude.ts;codex 是 home/,见 codex.ts)
|
|
12
|
+
*
|
|
13
|
+
* 刹车(网关 /v1/agents/:agent/config 的 runtime 字段):
|
|
14
|
+
* - blocked_versions:不装、不用;
|
|
15
|
+
* - min_version:低于它必须先升级;
|
|
16
|
+
* - max_tested_version:自动更新、pin 都不越过它;启动时已装的版本高于它也要换下来——
|
|
17
|
+
* u1s1 自己装的换成不超过它的最新版,用户自带的(PATH、U1S1_CLAUDE_BIN / U1S1_CODEX_BIN)不动,
|
|
18
|
+
* 另装一份不超限的来用。null = 不设上限。
|
|
19
|
+
*/
|
|
20
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
21
|
+
import { randomBytes } from "node:crypto";
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { posix, win32 } from "node:path";
|
|
24
|
+
import { stripSecretEnv } from "../secret-env.js";
|
|
25
|
+
const VERSION_RE = /^v?(\d{1,9})\.(\d{1,9})\.(\d{1,9})(?:-([0-9A-Za-z.-]{1,64}))?$/;
|
|
26
|
+
export function parseVersion(value) {
|
|
27
|
+
const m = typeof value === "string" ? VERSION_RE.exec(value.trim()) : null;
|
|
28
|
+
if (!m)
|
|
29
|
+
return undefined;
|
|
30
|
+
return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] };
|
|
31
|
+
}
|
|
32
|
+
export function isVersion(value) {
|
|
33
|
+
return typeof value === "string" && parseVersion(value) !== undefined;
|
|
34
|
+
}
|
|
35
|
+
/** 规范化:去掉前导 v、首尾空白;不是版本号返回 undefined。 */
|
|
36
|
+
export function normalizeVersion(value) {
|
|
37
|
+
if (typeof value !== "string")
|
|
38
|
+
return undefined;
|
|
39
|
+
const p = parseVersion(value);
|
|
40
|
+
return p ? `${p.nums.join(".")}${p.pre ? `-${p.pre}` : ""}` : undefined;
|
|
41
|
+
}
|
|
42
|
+
/** semver 比较(预发布段按标识符逐段比,数字段小于字母段;够用,不处理 build metadata)。 */
|
|
43
|
+
export function compareVersions(a, b) {
|
|
44
|
+
const pa = parseVersion(a);
|
|
45
|
+
const pb = parseVersion(b);
|
|
46
|
+
if (!pa || !pb)
|
|
47
|
+
throw new Error(`invalid version: ${!pa ? a : b}`);
|
|
48
|
+
for (let i = 0; i < 3; i++) {
|
|
49
|
+
const d = pa.nums[i] - pb.nums[i];
|
|
50
|
+
if (d !== 0)
|
|
51
|
+
return d < 0 ? -1 : 1;
|
|
52
|
+
}
|
|
53
|
+
if (pa.pre === pb.pre)
|
|
54
|
+
return 0;
|
|
55
|
+
if (!pa.pre)
|
|
56
|
+
return 1;
|
|
57
|
+
if (!pb.pre)
|
|
58
|
+
return -1;
|
|
59
|
+
const sa = pa.pre.split(".");
|
|
60
|
+
const sb = pb.pre.split(".");
|
|
61
|
+
for (let i = 0; i < Math.max(sa.length, sb.length); i++) {
|
|
62
|
+
const x = sa[i];
|
|
63
|
+
const y = sb[i];
|
|
64
|
+
if (x === undefined)
|
|
65
|
+
return -1;
|
|
66
|
+
if (y === undefined)
|
|
67
|
+
return 1;
|
|
68
|
+
const nx = /^\d+$/.test(x);
|
|
69
|
+
const ny = /^\d+$/.test(y);
|
|
70
|
+
if (nx && ny) {
|
|
71
|
+
const d = Number(x) - Number(y);
|
|
72
|
+
if (d !== 0)
|
|
73
|
+
return d < 0 ? -1 : 1;
|
|
74
|
+
}
|
|
75
|
+
else if (nx !== ny) {
|
|
76
|
+
return nx ? -1 : 1;
|
|
77
|
+
}
|
|
78
|
+
else if (x !== y) {
|
|
79
|
+
return x < y ? -1 : 1;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
/** `claude --version` 之类的输出 → 版本号(取第一个形如 x.y.z 的片段)。 */
|
|
85
|
+
export function versionFromOutput(output) {
|
|
86
|
+
const m = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(output);
|
|
87
|
+
return m ? normalizeVersion(m[1]) : undefined;
|
|
88
|
+
}
|
|
89
|
+
export const NO_BRAKE = { blockedVersions: [] };
|
|
90
|
+
/** 网关 runtime 字段 → RuntimeBrake;非法值按「没设」处理(blocked 里的非法项丢弃)。 */
|
|
91
|
+
export function brakeFromApi(runtime) {
|
|
92
|
+
const r = (runtime && typeof runtime === "object" ? runtime : {});
|
|
93
|
+
const blocked = Array.isArray(r["blocked_versions"])
|
|
94
|
+
? r["blocked_versions"].map(normalizeVersion).filter((v) => !!v)
|
|
95
|
+
: [];
|
|
96
|
+
return {
|
|
97
|
+
minVersion: normalizeVersion(r["min_version"]),
|
|
98
|
+
maxTestedVersion: normalizeVersion(r["max_tested_version"]),
|
|
99
|
+
blockedVersions: [...new Set(blocked)],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 安装与运行用同一套规则:blocked 不装不用、低于 min 不装不用、高于 max_tested 不装不用。
|
|
104
|
+
* 后台把 max_tested 调低,已装的更高版本下次启动时就会被换下来(见 planRuntime)。
|
|
105
|
+
*/
|
|
106
|
+
export function checkBrake(version, brake) {
|
|
107
|
+
if (brake.blockedVersions.some((b) => compareVersions(b, version) === 0))
|
|
108
|
+
return "blocked";
|
|
109
|
+
if (brake.minVersion && compareVersions(version, brake.minVersion) < 0)
|
|
110
|
+
return "below_min";
|
|
111
|
+
if (brake.maxTestedVersion && compareVersions(version, brake.maxTestedVersion) > 0)
|
|
112
|
+
return "above_max_tested";
|
|
113
|
+
return "ok";
|
|
114
|
+
}
|
|
115
|
+
export const allowedByBrake = (v, brake) => checkBrake(v, brake) === "ok";
|
|
116
|
+
/**
|
|
117
|
+
* 自动更新 / 首次安装的目标:候选(官方 latest、网关的 max_tested 等)里刹车允许安装的最高版本。
|
|
118
|
+
* latest 超过 max_tested 时自然退到 max_tested(它是测过的版本,必然已发布)。
|
|
119
|
+
*/
|
|
120
|
+
export function bestInstallTarget(candidates, brake) {
|
|
121
|
+
let best;
|
|
122
|
+
for (const c of candidates) {
|
|
123
|
+
const v = normalizeVersion(c);
|
|
124
|
+
if (!v || !allowedByBrake(v, brake))
|
|
125
|
+
continue;
|
|
126
|
+
if (!best || compareVersions(v, best) > 0)
|
|
127
|
+
best = v;
|
|
128
|
+
}
|
|
129
|
+
return best;
|
|
130
|
+
}
|
|
131
|
+
export const MANAGED_AGENTS = ["claude", "codex"];
|
|
132
|
+
export function isManagedAgent(value) {
|
|
133
|
+
return value === "claude" || value === "codex";
|
|
134
|
+
}
|
|
135
|
+
function npmOs(platform) {
|
|
136
|
+
return platform === "win32" ? "win32" : platform === "darwin" ? "darwin" : platform === "linux" ? "linux" : undefined;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* 官方包的 bin 就是 `bin/claude.exe`(各平台同名):postinstall 把对应平台的原生二进制
|
|
140
|
+
* 复制到这里。直接起这个文件,不经 node_modules/.bin 的 shell 包装(Windows 上是 .cmd)。
|
|
141
|
+
* 平台包名与官方 install.cjs 的 PLATFORMS 表一致。
|
|
142
|
+
*/
|
|
143
|
+
export const CLAUDE_RUNTIME = {
|
|
144
|
+
agent: "claude",
|
|
145
|
+
npmPackage: "@anthropic-ai/claude-code",
|
|
146
|
+
command: "claude",
|
|
147
|
+
binEnv: "U1S1_CLAUDE_BIN",
|
|
148
|
+
configDirEnv: "CLAUDE_CONFIG_DIR",
|
|
149
|
+
probeEnv: { DISABLE_AUTOUPDATER: "1" },
|
|
150
|
+
platformPackage: (version, platform, arch, musl) => {
|
|
151
|
+
const os = npmOs(platform);
|
|
152
|
+
if (!os || (arch !== "x64" && arch !== "arm64"))
|
|
153
|
+
return undefined;
|
|
154
|
+
const dir = `@anthropic-ai/claude-code-${os}-${arch}${os === "linux" && musl ? "-musl" : ""}`;
|
|
155
|
+
return { dir, name: dir, version };
|
|
156
|
+
},
|
|
157
|
+
binInNodeModules: () => "@anthropic-ai/claude-code/bin/claude.exe",
|
|
158
|
+
};
|
|
159
|
+
/** Codex 官方 bin/codex.js 的 PLATFORM_PACKAGE_BY_TARGET 表(0.167.2 核对);Linux 都是 musl 静态链接。 */
|
|
160
|
+
const CODEX_TRIPLES = {
|
|
161
|
+
"linux-x64": "x86_64-unknown-linux-musl",
|
|
162
|
+
"linux-arm64": "aarch64-unknown-linux-musl",
|
|
163
|
+
"darwin-x64": "x86_64-apple-darwin",
|
|
164
|
+
"darwin-arm64": "aarch64-apple-darwin",
|
|
165
|
+
"win32-x64": "x86_64-pc-windows-msvc",
|
|
166
|
+
"win32-arm64": "aarch64-pc-windows-msvc",
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* Codex:主包 `@openai/codex` 只有一个 JS 入口 `bin/codex.js`,平台二进制在 optionalDependencies 里,
|
|
170
|
+
* 以 npm 别名装进来(`@openai/codex-darwin-arm64` → `npm:@openai/codex@<版本>-darwin-arm64`)。
|
|
171
|
+
* 直接起平台包里的原生二进制,不经 codex.js(那层只是找二进制、设几个「由 npm 管理」的环境变量,
|
|
172
|
+
* 让它提示用 npm 升级;更新由启动器管,不需要)。原生二进制按自己所在目录找 rg 等附带文件。
|
|
173
|
+
*/
|
|
174
|
+
export const CODEX_RUNTIME = {
|
|
175
|
+
agent: "codex",
|
|
176
|
+
npmPackage: "@openai/codex",
|
|
177
|
+
command: "codex",
|
|
178
|
+
binEnv: "U1S1_CODEX_BIN",
|
|
179
|
+
configDirEnv: "CODEX_HOME",
|
|
180
|
+
probeEnv: {},
|
|
181
|
+
platformPackage: (version, platform, arch) => {
|
|
182
|
+
const os = npmOs(platform);
|
|
183
|
+
if (!os || !CODEX_TRIPLES[`${os}-${arch}`])
|
|
184
|
+
return undefined;
|
|
185
|
+
return { dir: `@openai/codex-${os}-${arch}`, name: "@openai/codex", version: `${version}-${os}-${arch}` };
|
|
186
|
+
},
|
|
187
|
+
binInNodeModules: (platform, arch) => {
|
|
188
|
+
const os = npmOs(platform);
|
|
189
|
+
const triple = os ? CODEX_TRIPLES[`${os}-${arch}`] : undefined;
|
|
190
|
+
if (!os || !triple)
|
|
191
|
+
return undefined;
|
|
192
|
+
return `@openai/codex-${os}-${arch}/vendor/${triple}/bin/${os === "win32" ? "codex.exe" : "codex"}`;
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
export function runtimeSpec(agent) {
|
|
196
|
+
if (agent === "claude")
|
|
197
|
+
return CLAUDE_RUNTIME;
|
|
198
|
+
if (agent === "codex")
|
|
199
|
+
return CODEX_RUNTIME;
|
|
200
|
+
throw new Error(`unknown agent runtime: ${String(agent)}`);
|
|
201
|
+
}
|
|
202
|
+
export function runtimePaths(agentsRoot, agent) {
|
|
203
|
+
const root = join(agentsRoot, agent);
|
|
204
|
+
return {
|
|
205
|
+
root,
|
|
206
|
+
runtimeDir: join(root, "runtime"),
|
|
207
|
+
stateFile: join(root, "state.json"),
|
|
208
|
+
npmCache: join(root, ".npm-cache"),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function join(...parts) {
|
|
212
|
+
return (process.platform === "win32" ? win32 : posix).join(...parts);
|
|
213
|
+
}
|
|
214
|
+
export function versionDir(paths, version) {
|
|
215
|
+
return join(paths.runtimeDir, version);
|
|
216
|
+
}
|
|
217
|
+
/** 可执行文件在某个 node_modules 根下的位置;不支持的平台给一个不会存在的路径(installedVersions 自然认不到)。 */
|
|
218
|
+
function binUnder(root, spec) {
|
|
219
|
+
const rel = spec.binInNodeModules(process.platform, process.arch, isMusl());
|
|
220
|
+
return rel ? join(root, "node_modules", ...rel.split("/")) : join(root, ".unsupported-platform");
|
|
221
|
+
}
|
|
222
|
+
export function managedBinPath(paths, spec, version) {
|
|
223
|
+
return binUnder(versionDir(paths, version), spec);
|
|
224
|
+
}
|
|
225
|
+
export function readRuntimeState(paths) {
|
|
226
|
+
try {
|
|
227
|
+
const raw = JSON.parse(readFileSync(paths.stateFile, "utf8"));
|
|
228
|
+
return {
|
|
229
|
+
current: normalizeVersion(raw["current"]),
|
|
230
|
+
pinned: normalizeVersion(raw["pinned"]),
|
|
231
|
+
lastCheckAt: typeof raw["lastCheckAt"] === "number" && Number.isFinite(raw["lastCheckAt"]) ? raw["lastCheckAt"] : undefined,
|
|
232
|
+
latest: normalizeVersion(raw["latest"]),
|
|
233
|
+
pending: normalizeVersion(raw["pending"]),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return {};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
export function writeRuntimeState(paths, state) {
|
|
241
|
+
mkdirSync(paths.root, { recursive: true, mode: 0o700 });
|
|
242
|
+
const tmp = `${paths.stateFile}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
243
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
|
|
244
|
+
renameSync(tmp, paths.stateFile);
|
|
245
|
+
}
|
|
246
|
+
export function updateRuntimeState(paths, patch) {
|
|
247
|
+
const next = { ...readRuntimeState(paths), ...patch };
|
|
248
|
+
for (const key of Object.keys(next))
|
|
249
|
+
if (next[key] === undefined)
|
|
250
|
+
delete next[key];
|
|
251
|
+
writeRuntimeState(paths, next);
|
|
252
|
+
return next;
|
|
253
|
+
}
|
|
254
|
+
/** runtime/ 下已装好的版本(目录名是版本号、可执行文件存在),从新到旧。 */
|
|
255
|
+
export function installedVersions(paths, spec, exists = existsSync) {
|
|
256
|
+
let names;
|
|
257
|
+
try {
|
|
258
|
+
names = readdirSync(paths.runtimeDir);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
return names
|
|
264
|
+
.filter((n) => isVersion(n) && normalizeVersion(n) === n && exists(managedBinPath(paths, spec, n)))
|
|
265
|
+
.sort((a, b) => compareVersions(b, a));
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* PATH 里找命令。Windows 只认 .exe:npm 全局装出来的 claude.cmd 要经 shell 才能起,
|
|
269
|
+
* 参数转义有坑(CVE-2024-27980 之后 Node 也不再直接 spawn .cmd),不值得冒险。
|
|
270
|
+
*/
|
|
271
|
+
export function findOnPath(command, opts) {
|
|
272
|
+
const exists = opts.exists ?? isFile;
|
|
273
|
+
const P = opts.platform === "win32" ? win32 : posix;
|
|
274
|
+
const pathValue = opts.env["PATH"] ?? opts.env["Path"] ?? "";
|
|
275
|
+
const names = opts.platform === "win32" ? [`${command}.exe`] : [command];
|
|
276
|
+
for (const dir of pathValue.split(opts.platform === "win32" ? ";" : ":")) {
|
|
277
|
+
if (!dir)
|
|
278
|
+
continue;
|
|
279
|
+
for (const name of names) {
|
|
280
|
+
const candidate = P.join(dir, name);
|
|
281
|
+
if (exists(candidate))
|
|
282
|
+
return candidate;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
function isFile(p) {
|
|
288
|
+
try {
|
|
289
|
+
return statSync(p).isFile();
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* 跑 `<bin> --version` 取版本。环境里去掉密钥、关掉自动更新;配置目录(CLAUDE_CONFIG_DIR /
|
|
297
|
+
* CODEX_HOME)指到启动器自己的目录,免得探测用户自己装的客户端时碰到用户的 ~/.claude、~/.codex。
|
|
298
|
+
*/
|
|
299
|
+
export function probeVersion(bin, configDir, spec = CLAUDE_RUNTIME) {
|
|
300
|
+
const env = stripSecretEnv(process.env);
|
|
301
|
+
for (const [key, value] of Object.entries(spec.probeEnv))
|
|
302
|
+
env[key] = value;
|
|
303
|
+
if (configDir)
|
|
304
|
+
env[spec.configDirEnv] = configDir;
|
|
305
|
+
else
|
|
306
|
+
delete env[spec.configDirEnv];
|
|
307
|
+
try {
|
|
308
|
+
const r = spawnSync(bin, ["--version"], { env, encoding: "utf8", timeout: 20_000, windowsHide: true });
|
|
309
|
+
if (r.status !== 0)
|
|
310
|
+
return undefined;
|
|
311
|
+
return versionFromOutput(`${r.stdout ?? ""}`);
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* 查找顺序:环境变量 → ~/.u1s1/agents/<客户端>(pin 优先) → PATH 里已有的同名命令 → 下载。
|
|
319
|
+
* 只做决定、不做 I/O 副作用;所有探测都是惰性的,用不到的不跑。
|
|
320
|
+
*
|
|
321
|
+
* 高于 max_tested 的版本一律不用:u1s1 自己装的换成不超过上限的版本(装不上不退回,没有
|
|
322
|
+
* fallback);环境变量指定的和 PATH 里的不替用户动,接着往下找 / 另装一份,并在 skipped 里说明。
|
|
323
|
+
*/
|
|
324
|
+
export async function planRuntime(input) {
|
|
325
|
+
const { brake, state } = input;
|
|
326
|
+
const autoUpdate = input.autoUpdate !== false;
|
|
327
|
+
const skipped = [];
|
|
328
|
+
const skip = (runtime) => skipped.push({ ...runtime, maxTestedVersion: brake.maxTestedVersion });
|
|
329
|
+
const notes = () => (skipped.length ? { skipped: [...skipped] } : {});
|
|
330
|
+
// 1. 环境变量:显式指定就只用它,跑不起来、被拉黑或太旧都直接报错,不悄悄换别的;
|
|
331
|
+
// 只是高于 max_tested 时不动它,往下找一份不超限的
|
|
332
|
+
const env = input.envBin ? await input.envBin() : undefined;
|
|
333
|
+
if (env) {
|
|
334
|
+
if (!env.version)
|
|
335
|
+
return { action: "error", code: "env_bin_unusable", bin: env.bin };
|
|
336
|
+
const verdict = checkBrake(env.version, brake);
|
|
337
|
+
const runtime = { source: "env", bin: env.bin, version: env.version };
|
|
338
|
+
if (verdict === "ok")
|
|
339
|
+
return { action: "use", runtime };
|
|
340
|
+
if (verdict !== "above_max_tested")
|
|
341
|
+
return { action: "error", code: "env_bin_brake", verdict, bin: env.bin, version: env.version };
|
|
342
|
+
skip(runtime);
|
|
343
|
+
}
|
|
344
|
+
const current = await input.managed();
|
|
345
|
+
const currentVerdict = current ? checkBrake(current.version, brake) : "ok";
|
|
346
|
+
const currentUsable = currentVerdict === "ok" ? current : undefined;
|
|
347
|
+
const firstManaged = input.installed().length === 0;
|
|
348
|
+
// 2. pin:优先于自动更新,但不能越过刹车(越过就忽略 pin,走自动逻辑并提示)
|
|
349
|
+
let pinIgnored;
|
|
350
|
+
if (state.pinned) {
|
|
351
|
+
if (allowedByBrake(state.pinned, brake)) {
|
|
352
|
+
if (current && compareVersions(current.version, state.pinned) === 0)
|
|
353
|
+
return { action: "use", runtime: current, ...notes() };
|
|
354
|
+
return {
|
|
355
|
+
action: "install",
|
|
356
|
+
version: state.pinned,
|
|
357
|
+
reason: "pin",
|
|
358
|
+
fallback: currentUsable,
|
|
359
|
+
needsConsent: firstManaged,
|
|
360
|
+
...notes(),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
pinIgnored = state.pinned;
|
|
364
|
+
}
|
|
365
|
+
// 3. 托管的当前版本
|
|
366
|
+
if (current) {
|
|
367
|
+
if (currentVerdict === "ok") {
|
|
368
|
+
const pending = state.pending;
|
|
369
|
+
if (autoUpdate && pending && compareVersions(pending, current.version) > 0 && allowedByBrake(pending, brake)) {
|
|
370
|
+
return { action: "install", version: pending, reason: "update", fallback: current, needsConsent: false, pinIgnored, ...notes() };
|
|
371
|
+
}
|
|
372
|
+
return { action: "use", runtime: current, pinIgnored, ...notes() };
|
|
373
|
+
}
|
|
374
|
+
// 被拉黑、低于最低版本或高于已验证的最高版本:必须先换,没有退路;自动更新关着就让用户自己换
|
|
375
|
+
if (!autoUpdate) {
|
|
376
|
+
return { action: "error", code: "update_required", verdict: currentVerdict, bin: current.bin, version: current.version };
|
|
377
|
+
}
|
|
378
|
+
if (currentVerdict === "above_max_tested")
|
|
379
|
+
skip(current);
|
|
380
|
+
const target = bestInstallTarget([await input.latest(), brake.maxTestedVersion, state.pending], brake);
|
|
381
|
+
if (!target)
|
|
382
|
+
return { action: "error", code: "no_version" };
|
|
383
|
+
return {
|
|
384
|
+
action: "install",
|
|
385
|
+
version: target,
|
|
386
|
+
reason: currentVerdict,
|
|
387
|
+
needsConsent: false,
|
|
388
|
+
pinIgnored,
|
|
389
|
+
...notes(),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
// 4. 用户机器上已有的命令,版本过得了刹车就直接用(省一次几百 MB 的下载);
|
|
393
|
+
// 高于 max_tested 的不动它,另装一份
|
|
394
|
+
const found = await input.onPath();
|
|
395
|
+
if (found) {
|
|
396
|
+
const verdict = checkBrake(found.version, brake);
|
|
397
|
+
if (verdict === "ok")
|
|
398
|
+
return { action: "use", runtime: found, pinIgnored, ...notes() };
|
|
399
|
+
if (verdict === "above_max_tested")
|
|
400
|
+
skip(found);
|
|
401
|
+
}
|
|
402
|
+
// 5. 首次下载
|
|
403
|
+
const target = bestInstallTarget([await input.latest(), brake.maxTestedVersion], brake);
|
|
404
|
+
if (!target)
|
|
405
|
+
return { action: "error", code: "no_version" };
|
|
406
|
+
return { action: "install", version: target, reason: "first", needsConsent: firstManaged, pinIgnored, ...notes() };
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* npm 查找顺序:
|
|
410
|
+
* 1. 随运行本 CLI 的 Node 一起的 npm(官方 Node 发行包的布局):
|
|
411
|
+
* Linux/macOS: <node 所在目录>/../lib/node_modules/npm/bin/npm-cli.js
|
|
412
|
+
* Windows: <node.exe 所在目录>/node_modules/npm/bin/npm-cli.js
|
|
413
|
+
* 便携包里 node 在 ~/.u1s1/node/bin/node(Windows ~/.u1s1/node/node.exe),所以打包时
|
|
414
|
+
* npm 放到 ~/.u1s1/node/lib/node_modules/npm(Windows ~/.u1s1/node/node_modules/npm)。
|
|
415
|
+
* 用当前 Node(process.execPath)直接跑 npm-cli.js,不依赖 PATH、不经 shell。
|
|
416
|
+
* 2. PATH 里的 npm。Windows 上 npm.cmd 旁边的 node_modules/npm/bin/npm-cli.js,
|
|
417
|
+
* 同样用 node 直接跑(不 spawn .cmd)。
|
|
418
|
+
*/
|
|
419
|
+
export function findNpm(opts) {
|
|
420
|
+
const exists = opts.exists ?? isFile;
|
|
421
|
+
const P = opts.platform === "win32" ? win32 : posix;
|
|
422
|
+
const nodeDir = P.dirname(opts.execPath);
|
|
423
|
+
const bundled = opts.platform === "win32"
|
|
424
|
+
? P.join(nodeDir, "node_modules", "npm", "bin", "npm-cli.js")
|
|
425
|
+
: P.join(nodeDir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js");
|
|
426
|
+
if (exists(bundled))
|
|
427
|
+
return { command: opts.execPath, prefixArgs: [P.normalize(bundled)], source: "bundled" };
|
|
428
|
+
const pathValue = opts.env["PATH"] ?? opts.env["Path"] ?? "";
|
|
429
|
+
for (const dir of pathValue.split(opts.platform === "win32" ? ";" : ":")) {
|
|
430
|
+
if (!dir)
|
|
431
|
+
continue;
|
|
432
|
+
if (opts.platform === "win32") {
|
|
433
|
+
if (!exists(P.join(dir, "npm.cmd")))
|
|
434
|
+
continue;
|
|
435
|
+
const cli = P.join(dir, "node_modules", "npm", "bin", "npm-cli.js");
|
|
436
|
+
if (exists(cli))
|
|
437
|
+
return { command: opts.execPath, prefixArgs: [cli], source: "path" };
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
const npm = P.join(dir, "npm");
|
|
441
|
+
if (exists(npm))
|
|
442
|
+
return { command: npm, prefixArgs: [], source: "path" };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
/** 中国站走国内 npm 镜像(同一份包,带完整性校验);U1S1_NPM_REGISTRY 可覆盖。 */
|
|
448
|
+
export function npmRegistryFor(site, env = process.env) {
|
|
449
|
+
const override = env["U1S1_NPM_REGISTRY"]?.trim();
|
|
450
|
+
if (override && /^https?:\/\//.test(override))
|
|
451
|
+
return override.replace(/\/+$/, "");
|
|
452
|
+
return site === "cn" ? "https://registry.npmmirror.com" : "https://registry.npmjs.org";
|
|
453
|
+
}
|
|
454
|
+
/** npm 的平台二进制子包在 node_modules 下的目录名。 */
|
|
455
|
+
export function platformPackage(spec, platform = process.platform, arch = process.arch, musl = isMusl()) {
|
|
456
|
+
return spec.platformPackage("0.0.0", platform, arch, musl)?.dir;
|
|
457
|
+
}
|
|
458
|
+
/** 某个版本的平台二进制子包(目录名 + npm 源上的名字与版本)。 */
|
|
459
|
+
export function platformPackageRef(spec, version, platform = process.platform, arch = process.arch, musl = isMusl()) {
|
|
460
|
+
return spec.platformPackage(version, platform, arch, musl);
|
|
461
|
+
}
|
|
462
|
+
function isMusl() {
|
|
463
|
+
if (process.platform !== "linux")
|
|
464
|
+
return false;
|
|
465
|
+
try {
|
|
466
|
+
const report = process.report?.getReport();
|
|
467
|
+
return !report?.header?.glibcVersionRuntime;
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
function registryUrl(registry, pkg, tag) {
|
|
474
|
+
return `${registry}/${pkg.replace("/", "%2f")}/${encodeURIComponent(tag)}`;
|
|
475
|
+
}
|
|
476
|
+
export async function fetchPackageMeta(registry, pkg, tag, fetchImpl = fetch) {
|
|
477
|
+
try {
|
|
478
|
+
const res = await fetchImpl(registryUrl(registry, pkg, tag), {
|
|
479
|
+
headers: { Accept: "application/json" },
|
|
480
|
+
signal: AbortSignal.timeout(10_000),
|
|
481
|
+
});
|
|
482
|
+
if (!res.ok)
|
|
483
|
+
return undefined;
|
|
484
|
+
const text = await res.text();
|
|
485
|
+
if (text.length > 2 * 1024 * 1024)
|
|
486
|
+
return undefined;
|
|
487
|
+
const body = JSON.parse(text);
|
|
488
|
+
const version = normalizeVersion(body.version);
|
|
489
|
+
if (!version)
|
|
490
|
+
return undefined;
|
|
491
|
+
const dist = body.dist ?? {};
|
|
492
|
+
return {
|
|
493
|
+
version,
|
|
494
|
+
integrity: typeof dist["integrity"] === "string" ? dist["integrity"] : undefined,
|
|
495
|
+
tarball: typeof dist["tarball"] === "string" ? dist["tarball"] : undefined,
|
|
496
|
+
unpackedSize: typeof dist["unpackedSize"] === "number" ? dist["unpackedSize"] : undefined,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
return undefined;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
export async function fetchLatestVersion(registry, pkg, fetchImpl = fetch) {
|
|
504
|
+
return (await fetchPackageMeta(registry, pkg, "latest", fetchImpl))?.version;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* 压缩包大小。npm 源对 HEAD 不一定给 content-length(registry.npmjs.org 就不给),
|
|
508
|
+
* 用只取 1 字节的 Range 请求读 content-range 里的总长;国内镜像会 302 到 CDN,fetch 自动跟随。
|
|
509
|
+
*/
|
|
510
|
+
export async function downloadSize(url, fetchImpl = fetch) {
|
|
511
|
+
if (!url)
|
|
512
|
+
return undefined;
|
|
513
|
+
try {
|
|
514
|
+
const res = await fetchImpl(url, { headers: { Range: "bytes=0-0" }, signal: AbortSignal.timeout(8_000) });
|
|
515
|
+
const range = res.headers.get("content-range");
|
|
516
|
+
let n = range ? Number(/\/(\d+)\s*$/.exec(range)?.[1]) : Number.NaN;
|
|
517
|
+
if (!(n > 0) && res.status === 200)
|
|
518
|
+
n = Number(res.headers.get("content-length"));
|
|
519
|
+
await res.body?.cancel().catch(() => { });
|
|
520
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
return undefined;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
export class RuntimeInstallError extends Error {
|
|
527
|
+
code;
|
|
528
|
+
detail;
|
|
529
|
+
/** 这次安装本来是要换下这些超出 max_tested 的版本(ensureAgentRuntime 填,给调用方说明后果) */
|
|
530
|
+
skipped;
|
|
531
|
+
constructor(code, message, detail) {
|
|
532
|
+
super(message);
|
|
533
|
+
this.code = code;
|
|
534
|
+
this.detail = detail;
|
|
535
|
+
this.name = "RuntimeInstallError";
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* 装一个确切版本:npm install 到暂存目录 → 校验(锁文件里的 integrity 与 registry 元数据一致、
|
|
540
|
+
* 可执行文件报出的版本号就是目标版本)→ 整目录改名成 runtime/<版本>。中途失败不影响已装的版本。
|
|
541
|
+
*/
|
|
542
|
+
export async function installRuntime(opts) {
|
|
543
|
+
const { spec, paths, version, registry, hooks } = opts;
|
|
544
|
+
const plat = platformPackageRef(spec, version);
|
|
545
|
+
if (!plat)
|
|
546
|
+
throw new RuntimeInstallError("unsupported_platform", hooks.text.unsupported());
|
|
547
|
+
const [mainMeta, platMeta] = await Promise.all([
|
|
548
|
+
fetchPackageMeta(registry, spec.npmPackage, version),
|
|
549
|
+
fetchPackageMeta(registry, plat.name, plat.version),
|
|
550
|
+
]);
|
|
551
|
+
const downloadBytes = await downloadSize(platMeta?.tarball);
|
|
552
|
+
if (opts.needsConsent) {
|
|
553
|
+
const ok = hooks.consent
|
|
554
|
+
? await hooks.consent({
|
|
555
|
+
agent: spec.agent,
|
|
556
|
+
npmPackage: spec.npmPackage,
|
|
557
|
+
version,
|
|
558
|
+
registry,
|
|
559
|
+
downloadBytes,
|
|
560
|
+
unpackedBytes: platMeta?.unpackedSize !== undefined
|
|
561
|
+
? platMeta.unpackedSize + (mainMeta?.unpackedSize ?? 0)
|
|
562
|
+
: undefined,
|
|
563
|
+
})
|
|
564
|
+
: false;
|
|
565
|
+
if (!ok)
|
|
566
|
+
throw new RuntimeInstallError("declined", hooks.text.declined());
|
|
567
|
+
}
|
|
568
|
+
const npm = opts.npm ?? findNpm({ execPath: process.execPath, platform: process.platform, env: process.env });
|
|
569
|
+
if (!npm)
|
|
570
|
+
throw new RuntimeInstallError("no_npm", hooks.text.noNpm());
|
|
571
|
+
mkdirSync(paths.runtimeDir, { recursive: true, mode: 0o700 });
|
|
572
|
+
const maxAttempts = opts.maxAttempts ?? 3;
|
|
573
|
+
const retryDelayMs = opts.retryDelayMs ?? 3_000;
|
|
574
|
+
hooks.log(hooks.text.installing(version));
|
|
575
|
+
let lastOutput = "";
|
|
576
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
577
|
+
const staging = join(paths.runtimeDir, `.staging-${version}-${randomBytes(4).toString("hex")}`);
|
|
578
|
+
mkdirSync(staging, { recursive: true });
|
|
579
|
+
try {
|
|
580
|
+
const result = await runNpmInstall(npm, {
|
|
581
|
+
staging,
|
|
582
|
+
cache: paths.npmCache,
|
|
583
|
+
spec: `${spec.npmPackage}@${version}`,
|
|
584
|
+
registry,
|
|
585
|
+
progress: hooks.progress,
|
|
586
|
+
totalBytes: downloadBytes,
|
|
587
|
+
});
|
|
588
|
+
lastOutput = result.output;
|
|
589
|
+
if (result.code !== 0) {
|
|
590
|
+
hooks.log(hooks.text.npmFailed(attempt, maxAttempts));
|
|
591
|
+
rmSync(staging, { recursive: true, force: true });
|
|
592
|
+
if (attempt < maxAttempts) {
|
|
593
|
+
hooks.log(hooks.text.retrying(Math.round((retryDelayMs * attempt) / 1000)));
|
|
594
|
+
await new Promise((r) => setTimeout(r, retryDelayMs * attempt));
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
throw new RuntimeInstallError("npm_failed", hooks.text.npmFailed(attempt, maxAttempts), tail(lastOutput));
|
|
598
|
+
}
|
|
599
|
+
const problem = verifyStaging({
|
|
600
|
+
spec,
|
|
601
|
+
staging,
|
|
602
|
+
version,
|
|
603
|
+
platPkg: plat.dir,
|
|
604
|
+
platVersion: plat.version,
|
|
605
|
+
mainMeta,
|
|
606
|
+
platMeta,
|
|
607
|
+
probe: (bin) => probeVersion(bin, opts.probeConfigDir, spec),
|
|
608
|
+
});
|
|
609
|
+
if (problem) {
|
|
610
|
+
rmSync(staging, { recursive: true, force: true });
|
|
611
|
+
throw new RuntimeInstallError("verify_failed", hooks.text.verifyFailed(problem), tail(lastOutput));
|
|
612
|
+
}
|
|
613
|
+
const target = versionDir(paths, version);
|
|
614
|
+
rmSync(target, { recursive: true, force: true });
|
|
615
|
+
try {
|
|
616
|
+
renameSync(staging, target);
|
|
617
|
+
}
|
|
618
|
+
catch (e) {
|
|
619
|
+
// 另一个 u1s1 进程刚好装好了同一版本:用它的
|
|
620
|
+
rmSync(staging, { recursive: true, force: true });
|
|
621
|
+
if (!existsSync(managedBinPath(paths, spec, version)))
|
|
622
|
+
throw e;
|
|
623
|
+
}
|
|
624
|
+
const bin = managedBinPath(paths, spec, version);
|
|
625
|
+
updateRuntimeState(paths, { current: version, pending: undefined });
|
|
626
|
+
pruneOldVersions(paths, version);
|
|
627
|
+
rmSync(paths.npmCache, { recursive: true, force: true });
|
|
628
|
+
hooks.log(hooks.text.installed(version));
|
|
629
|
+
return { source: "managed", bin, version };
|
|
630
|
+
}
|
|
631
|
+
catch (e) {
|
|
632
|
+
rmSync(staging, { recursive: true, force: true });
|
|
633
|
+
throw e;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
throw new RuntimeInstallError("npm_failed", hooks.text.npmFailed(maxAttempts, maxAttempts), tail(lastOutput));
|
|
637
|
+
}
|
|
638
|
+
function tail(text, lines = 15) {
|
|
639
|
+
return text.trim().split(/\r?\n/).slice(-lines).join("\n");
|
|
640
|
+
}
|
|
641
|
+
/** 只留当前版本;删不掉的(Windows 上还在跑的 .exe)留到下次。 */
|
|
642
|
+
function pruneOldVersions(paths, keep) {
|
|
643
|
+
let names = [];
|
|
644
|
+
try {
|
|
645
|
+
names = readdirSync(paths.runtimeDir);
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
for (const name of names) {
|
|
651
|
+
if (name === keep)
|
|
652
|
+
continue;
|
|
653
|
+
try {
|
|
654
|
+
rmSync(join(paths.runtimeDir, name), { recursive: true, force: true });
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
// 忽略
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* 完整性校验。npm 自己已按 registry 元数据里的 integrity 校验过每个压缩包;这里再确认
|
|
663
|
+
* 装进来的正是我们要的那两个包、那个版本(锁文件条目与刚取的元数据一致),
|
|
664
|
+
* 且可执行文件能跑、报出的就是目标版本(postinstall 没装上平台二进制时这里会失败)。
|
|
665
|
+
*/
|
|
666
|
+
export function verifyStaging(input) {
|
|
667
|
+
const { spec, staging, version, platPkg } = input;
|
|
668
|
+
let lock;
|
|
669
|
+
try {
|
|
670
|
+
lock = JSON.parse(readFileSync(join(staging, "node_modules", ".package-lock.json"), "utf8"));
|
|
671
|
+
}
|
|
672
|
+
catch {
|
|
673
|
+
return "missing node_modules/.package-lock.json";
|
|
674
|
+
}
|
|
675
|
+
const check = (pkg, meta, want = version) => {
|
|
676
|
+
const entry = lock.packages?.[`node_modules/${pkg}`];
|
|
677
|
+
if (!entry)
|
|
678
|
+
return `${pkg} not installed`;
|
|
679
|
+
if (entry.version !== want)
|
|
680
|
+
return `${pkg} version ${String(entry.version)} != ${want}`;
|
|
681
|
+
if (!entry.integrity)
|
|
682
|
+
return `${pkg} has no integrity`;
|
|
683
|
+
if (meta?.integrity && entry.integrity !== meta.integrity)
|
|
684
|
+
return `${pkg} integrity mismatch`;
|
|
685
|
+
return undefined;
|
|
686
|
+
};
|
|
687
|
+
const problem = check(spec.npmPackage, input.mainMeta) ?? check(platPkg, input.platMeta, input.platVersion ?? version);
|
|
688
|
+
if (problem)
|
|
689
|
+
return problem;
|
|
690
|
+
const bin = binUnder(staging, spec);
|
|
691
|
+
const got = (input.probe ?? ((b) => probeVersion(b, undefined, spec)))(bin);
|
|
692
|
+
if (got !== version)
|
|
693
|
+
return `binary reports ${got ?? "nothing"}, expected ${version}`;
|
|
694
|
+
return undefined;
|
|
695
|
+
}
|
|
696
|
+
export function npmInstallArgs(o) {
|
|
697
|
+
return [
|
|
698
|
+
"install",
|
|
699
|
+
o.spec,
|
|
700
|
+
"--prefix", o.staging,
|
|
701
|
+
"--registry", o.registry,
|
|
702
|
+
// 用自己的缓存目录:不碰用户的 ~/.npm,下载量也能按目录大小算进度
|
|
703
|
+
"--cache", o.cache,
|
|
704
|
+
"--include=optional",
|
|
705
|
+
"--no-audit",
|
|
706
|
+
"--no-fund",
|
|
707
|
+
"--no-update-notifier",
|
|
708
|
+
"--loglevel=error",
|
|
709
|
+
];
|
|
710
|
+
}
|
|
711
|
+
/** npm 的子进程环境:去掉密钥;当前 Node 所在目录放到 PATH 最前,postinstall 的 `node install.cjs` 用的就是它。 */
|
|
712
|
+
export function npmEnv(base, execPath, platform = process.platform) {
|
|
713
|
+
const env = stripSecretEnv(base);
|
|
714
|
+
const P = platform === "win32" ? win32 : posix;
|
|
715
|
+
const sep = platform === "win32" ? ";" : ":";
|
|
716
|
+
const pathKey = platform === "win32" ? (Object.keys(env).find((k) => k.toUpperCase() === "PATH") ?? "Path") : "PATH";
|
|
717
|
+
const current = env[pathKey] ?? "";
|
|
718
|
+
env[pathKey] = current ? `${P.dirname(execPath)}${sep}${current}` : P.dirname(execPath);
|
|
719
|
+
env["npm_config_update_notifier"] = "false";
|
|
720
|
+
env["npm_config_fund"] = "false";
|
|
721
|
+
env["npm_config_audit"] = "false";
|
|
722
|
+
return env;
|
|
723
|
+
}
|
|
724
|
+
function runNpmInstall(npm, o) {
|
|
725
|
+
return new Promise((resolve) => {
|
|
726
|
+
const child = spawn(npm.command, [...npm.prefixArgs, ...npmInstallArgs(o)], {
|
|
727
|
+
env: npmEnv(process.env, process.execPath),
|
|
728
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
729
|
+
windowsHide: true,
|
|
730
|
+
});
|
|
731
|
+
let output = "";
|
|
732
|
+
const append = (chunk) => {
|
|
733
|
+
output += chunk.toString("utf8");
|
|
734
|
+
if (output.length > 64 * 1024)
|
|
735
|
+
output = output.slice(-64 * 1024);
|
|
736
|
+
};
|
|
737
|
+
child.stdout?.on("data", append);
|
|
738
|
+
child.stderr?.on("data", append);
|
|
739
|
+
const baseline = dirSize(o.cache);
|
|
740
|
+
const timer = o.progress
|
|
741
|
+
? setInterval(() => o.progress(Math.max(0, dirSize(o.cache) - baseline), o.totalBytes), 1_000)
|
|
742
|
+
: undefined;
|
|
743
|
+
const done = (code) => {
|
|
744
|
+
if (timer)
|
|
745
|
+
clearInterval(timer);
|
|
746
|
+
resolve({ code, output });
|
|
747
|
+
};
|
|
748
|
+
child.on("error", (e) => {
|
|
749
|
+
output += String(e);
|
|
750
|
+
done(1);
|
|
751
|
+
});
|
|
752
|
+
child.on("exit", (code) => done(code ?? 1));
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
function dirSize(dir) {
|
|
756
|
+
let total = 0;
|
|
757
|
+
const walk = (d, depth) => {
|
|
758
|
+
if (depth > 8)
|
|
759
|
+
return;
|
|
760
|
+
let entries;
|
|
761
|
+
try {
|
|
762
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
for (const e of entries) {
|
|
768
|
+
const p = join(d, e.name);
|
|
769
|
+
if (e.isDirectory())
|
|
770
|
+
walk(p, depth + 1);
|
|
771
|
+
else if (e.isFile()) {
|
|
772
|
+
try {
|
|
773
|
+
total += statSync(p).size;
|
|
774
|
+
}
|
|
775
|
+
catch {
|
|
776
|
+
// 忽略
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
walk(dir, 0);
|
|
782
|
+
return total;
|
|
783
|
+
}
|
|
784
|
+
// ---------------------------------------------------------------- daily check
|
|
785
|
+
export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
786
|
+
/**
|
|
787
|
+
* 每天最多查一次官方最新版;算出刹车允许的目标版本高于当前托管版本时记为 pending,
|
|
788
|
+
* 下次启动前安装(planRuntime 的 update 分支)。pin 了就不记 pending。
|
|
789
|
+
*/
|
|
790
|
+
export async function dailyUpdateCheck(input) {
|
|
791
|
+
const state = readRuntimeState(input.paths);
|
|
792
|
+
if (!input.force && state.lastCheckAt !== undefined && input.now - state.lastCheckAt < UPDATE_CHECK_INTERVAL_MS
|
|
793
|
+
&& input.now >= state.lastCheckAt) {
|
|
794
|
+
return state;
|
|
795
|
+
}
|
|
796
|
+
const latest = await input.latest();
|
|
797
|
+
if (!latest)
|
|
798
|
+
return state; // 查不到不记时间,下次启动再试
|
|
799
|
+
const patch = { lastCheckAt: input.now, latest };
|
|
800
|
+
const target = bestInstallTarget([latest, input.brake.maxTestedVersion], input.brake);
|
|
801
|
+
if (!state.pinned && state.current && target && compareVersions(target, state.current) > 0)
|
|
802
|
+
patch.pending = target;
|
|
803
|
+
else
|
|
804
|
+
patch.pending = undefined;
|
|
805
|
+
return updateRuntimeState(input.paths, patch);
|
|
806
|
+
}
|
|
807
|
+
export class RuntimePlanError extends Error {
|
|
808
|
+
plan;
|
|
809
|
+
constructor(plan) {
|
|
810
|
+
super(`agent runtime unavailable: ${plan.code}`);
|
|
811
|
+
this.plan = plan;
|
|
812
|
+
this.name = "RuntimePlanError";
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* 找到(必要时下载或更新)可用的客户端运行时。CLI(`u1s1 claude` / `u1s1 codex`)与桌面端共用。
|
|
817
|
+
* 失败抛 RuntimePlanError(刹车/版本问题)或 RuntimeInstallError(下载问题)。
|
|
818
|
+
*/
|
|
819
|
+
export async function ensureAgentRuntime(agent, opts) {
|
|
820
|
+
const spec = runtimeSpec(agent);
|
|
821
|
+
const paths = runtimePaths(opts.agentsRoot, agent);
|
|
822
|
+
const env = opts.env ?? process.env;
|
|
823
|
+
const registry = npmRegistryFor(opts.site, env);
|
|
824
|
+
const probe = (bin) => probeVersion(bin, opts.probeConfigDir, spec);
|
|
825
|
+
const state = readRuntimeState(paths);
|
|
826
|
+
let latestMemo;
|
|
827
|
+
const plan = await planRuntime({
|
|
828
|
+
brake: opts.brake,
|
|
829
|
+
state,
|
|
830
|
+
envBin: async () => {
|
|
831
|
+
const bin = env[spec.binEnv]?.trim();
|
|
832
|
+
return bin ? { bin, version: probe(bin) } : undefined;
|
|
833
|
+
},
|
|
834
|
+
managed: async () => {
|
|
835
|
+
const installed = installedVersions(paths, spec);
|
|
836
|
+
const version = state.current && installed.includes(state.current) ? state.current : installed[0];
|
|
837
|
+
if (!version)
|
|
838
|
+
return undefined;
|
|
839
|
+
const bin = managedBinPath(paths, spec, version);
|
|
840
|
+
// 托管目录只放校验过的版本,目录名就是版本号,启动时不必每次再跑一遍 --version
|
|
841
|
+
return { source: "managed", bin, version };
|
|
842
|
+
},
|
|
843
|
+
installed: () => installedVersions(paths, spec),
|
|
844
|
+
onPath: async () => {
|
|
845
|
+
const bin = findOnPath(spec.command, { env, platform: process.platform });
|
|
846
|
+
const version = bin ? probe(bin) : undefined;
|
|
847
|
+
return bin && version ? { source: "path", bin, version } : undefined;
|
|
848
|
+
},
|
|
849
|
+
latest: () => (latestMemo ??= fetchLatestVersion(registry, spec.npmPackage)),
|
|
850
|
+
autoUpdate: opts.autoUpdate,
|
|
851
|
+
});
|
|
852
|
+
if (plan.action === "error")
|
|
853
|
+
throw new RuntimePlanError(plan);
|
|
854
|
+
const overMax = opts.hooks.text.overMaxTested;
|
|
855
|
+
if (overMax)
|
|
856
|
+
for (const s of plan.skipped ?? [])
|
|
857
|
+
opts.hooks.log(overMax(s));
|
|
858
|
+
const extra = { pinIgnored: plan.pinIgnored, ...(plan.skipped ? { skipped: plan.skipped } : {}) };
|
|
859
|
+
if (plan.action === "use")
|
|
860
|
+
return { ...plan.runtime, ...extra };
|
|
861
|
+
try {
|
|
862
|
+
const installed = await (opts.install ?? installRuntime)({
|
|
863
|
+
spec,
|
|
864
|
+
paths,
|
|
865
|
+
version: plan.version,
|
|
866
|
+
registry,
|
|
867
|
+
needsConsent: plan.needsConsent,
|
|
868
|
+
hooks: opts.hooks,
|
|
869
|
+
probeConfigDir: opts.probeConfigDir,
|
|
870
|
+
});
|
|
871
|
+
return { ...installed, ...extra };
|
|
872
|
+
}
|
|
873
|
+
catch (e) {
|
|
874
|
+
if (plan.fallback && e instanceof RuntimeInstallError && e.code !== "declined") {
|
|
875
|
+
// 自动更新失败:清掉 pending,别每次启动都卡在重试上;下次每日检查会再记
|
|
876
|
+
if (plan.reason === "update")
|
|
877
|
+
updateRuntimeState(paths, { pending: undefined });
|
|
878
|
+
return { ...plan.fallback, ...extra, installError: e };
|
|
879
|
+
}
|
|
880
|
+
if (e instanceof RuntimeInstallError && plan.skipped)
|
|
881
|
+
e.skipped = plan.skipped;
|
|
882
|
+
throw e;
|
|
883
|
+
}
|
|
884
|
+
}
|