@whobot/cli 1.0.0 → 2.0.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/README.md +18 -5
- package/bin/install.mjs +222 -87
- package/bootstrap.json +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -14,15 +14,24 @@ npx --yes @whobot/cli@latest install
|
|
|
14
14
|
|
|
15
15
|
全局包已安装而当前 PATH 看不到 `whobot` 时,使用 npm prefix 解析出的全局入口绝对路径,仍经过 JS 启动器;不要枚举 CLI 数据目录下的 Go 文件。
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
首次从 v1 升级到 v2 时,先从原渠道获取最新稳定版的具体版本,再执行升级提示中的固定版本安装命令,例如目标为 `2.0.0` 时:
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
|
|
21
|
-
# 或
|
|
22
|
-
npx --yes @whobot/cli@latest install
|
|
20
|
+
npx --yes @whobot/cli@2.0.0 install
|
|
23
21
|
```
|
|
24
22
|
|
|
25
|
-
|
|
23
|
+
首次迁移不要运行旧的 `update apply`,也不要直接复用 `npx @whobot/cli@latest install` 或 `@intranet install`。已发布 v1 的全局程序可能软链接到同一标签的 npx 缓存;再次解析同标签可能在安装器启动前覆盖旧程序,使活动检查和原凭据端点校验失去依据。固定的新版本使用独立缓存入口,安装器先检查旧身份、原编译端点和活动通话,再替换程序。新安装的全局包与 npx 缓存分离。
|
|
24
|
+
|
|
25
|
+
v2 后续更新:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
whobot update
|
|
29
|
+
# 确认后执行返回的 next.command,包含实际目标版本
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
更新走 npm,不向数据 Home/bin 写入程序文件。安装成功会核对 npm 注册、实际二进制版本与协议、命令入口;PATH 仍优先解析旧入口时明确失败。存在身份但原程序损坏、端点符号缺失或新旧 API Origin / OAuth Issuer 不同,必须先恢复原程序或使用原端点的发行,不会清空身份来绕过检查。
|
|
33
|
+
|
|
34
|
+
CLI 软件安装完成后,还需要通过原平台或官方完整包更新 Skill,重载宿主或新建任务,再执行一次预检。安装器不覆盖宿主 Skill,也不将 CLI 完成当作联合升级完成。
|
|
26
35
|
|
|
27
36
|
## 程序位置与数据目录
|
|
28
37
|
|
|
@@ -30,6 +39,8 @@ npx --yes @whobot/cli@latest install
|
|
|
30
39
|
|
|
31
40
|
## 校验
|
|
32
41
|
|
|
42
|
+
SHA-256 用于核对下载内容与 manifest 一致,不声称提供公开签名或来源认证。
|
|
43
|
+
|
|
33
44
|
- 只接受已知 `schemaVersion`、OS(darwin/linux/windows)与 arch(amd64/arm64)
|
|
34
45
|
- 解压前核对 size 与 SHA-256
|
|
35
46
|
- 拒绝路径穿越、重名、意外顶层文件、缺失 checksum、制品内脚本
|
|
@@ -41,3 +52,5 @@ npx --yes @whobot/cli@latest install
|
|
|
41
52
|
```bash
|
|
42
53
|
bun test
|
|
43
54
|
```
|
|
55
|
+
|
|
56
|
+
发布人员在启用最低版本前,应对实际发行源执行仓库的 `bun deploy/check-upgrade-release.mjs latest`(内网使用 `intranet`),验证两项门槛可满足、完整 Skill 包和说明可取,以及隔离 npm/native 安装成功。配置、先制品后门槛的顺序与用户人工宿主清单见仓库 `docs/releases/skill-cli-v2-cutover.md`;检查通过只覆盖当前平台,不表示真实宿主已验收。
|
package/bin/install.mjs
CHANGED
|
@@ -28,10 +28,32 @@ const pkg = JSON.parse(
|
|
|
28
28
|
await fs.readFile(path.join(pkgRoot, "package.json"), "utf8"),
|
|
29
29
|
);
|
|
30
30
|
|
|
31
|
+
// The release profile (ADR-0015) is baked into the published package: its npm
|
|
32
|
+
// entry command is the single package.json bin key and its CLI home directory
|
|
33
|
+
// is bootstrap.homeName.
|
|
34
|
+
const COMMAND = cliCommand(pkg);
|
|
35
|
+
const HOME_NAME = cliHomeName(bootstrap);
|
|
36
|
+
|
|
37
|
+
function cliCommand(p) {
|
|
38
|
+
const names = Object.keys(p.bin || {});
|
|
39
|
+
if (names.length !== 1 || !/^whobot(-[a-z]+)?$/.test(names[0])) {
|
|
40
|
+
throw new Error(`package.json must register exactly one whobot command, got ${JSON.stringify(p.bin)}`);
|
|
41
|
+
}
|
|
42
|
+
return names[0];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function cliHomeName(b) {
|
|
46
|
+
const name = String(b.homeName || "");
|
|
47
|
+
if (!/^\.whobot(-[a-z]+)?-cli$/.test(name)) {
|
|
48
|
+
throw new Error(`bootstrap.json homeName is missing or invalid: ${JSON.stringify(b.homeName)}`);
|
|
49
|
+
}
|
|
50
|
+
return name;
|
|
51
|
+
}
|
|
52
|
+
|
|
31
53
|
const OS_MAP = { darwin: "darwin", linux: "linux", win32: "windows" };
|
|
32
54
|
const ARCH_MAP = { x64: "amd64", arm64: "arm64" };
|
|
33
|
-
const BINARY_UNIX =
|
|
34
|
-
const BINARY_WINDOWS =
|
|
55
|
+
const BINARY_UNIX = COMMAND;
|
|
56
|
+
const BINARY_WINDOWS = `${COMMAND}.exe`;
|
|
35
57
|
const ALLOWED_FILES = new Set([
|
|
36
58
|
BINARY_UNIX,
|
|
37
59
|
BINARY_WINDOWS,
|
|
@@ -48,6 +70,8 @@ const ALLOWED_FILES = new Set([
|
|
|
48
70
|
const DIST_TAGS = new Set(["latest", "next", "canary", "stable", "intranet", "*"]);
|
|
49
71
|
const SEMVER =
|
|
50
72
|
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
73
|
+
const STABLE_TARGET =
|
|
74
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
51
75
|
const SHA256_HEX = /^[0-9a-fA-F]{64}$/;
|
|
52
76
|
const DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
53
77
|
const SMOKE_TIMEOUT_MS = 15_000;
|
|
@@ -69,12 +93,19 @@ export class InstallerError extends Error {
|
|
|
69
93
|
function writeJSONFailure(code, message) {
|
|
70
94
|
const body = {
|
|
71
95
|
ok: false,
|
|
72
|
-
error: {
|
|
96
|
+
error: {
|
|
97
|
+
code,
|
|
98
|
+
category: code === "CHANNEL_INVALID" ? "input" : "unavailable",
|
|
99
|
+
userMessage: code === "CHANNEL_INVALID" ? "安装包的发行渠道信息有误,请重新安装。" : "CLI 更新未完成,已有身份数据已保留。",
|
|
100
|
+
detail: message,
|
|
101
|
+
retry: { safe: false },
|
|
102
|
+
},
|
|
103
|
+
next: { action: "stop" },
|
|
73
104
|
meta: {
|
|
74
105
|
requestId: "req_00000000000000000000000000000000",
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
106
|
+
cli: pkg.version,
|
|
107
|
+
protocol: 2,
|
|
108
|
+
notices: [],
|
|
78
109
|
},
|
|
79
110
|
};
|
|
80
111
|
process.stdout.write(`${JSON.stringify(body)}\n`);
|
|
@@ -88,10 +119,8 @@ function bakedChannel() {
|
|
|
88
119
|
return ch;
|
|
89
120
|
}
|
|
90
121
|
|
|
91
|
-
const WORKSPACE_HOME_NAME = ".whobot-cli";
|
|
92
|
-
|
|
93
122
|
function logStderr(message) {
|
|
94
|
-
process.stderr.write(
|
|
123
|
+
process.stderr.write(`${COMMAND}: ${message}\n`);
|
|
95
124
|
}
|
|
96
125
|
|
|
97
126
|
export function nativeBinaryPath(root = pkgRoot) {
|
|
@@ -197,7 +226,7 @@ export async function runPostinstall(env) {
|
|
|
197
226
|
}
|
|
198
227
|
}
|
|
199
228
|
|
|
200
|
-
export function whichCommand(name, env) {
|
|
229
|
+
export function whichCommand(name, env, ignoreRealpath = "") {
|
|
201
230
|
const pathEnv = env.PATH || env.Path || "";
|
|
202
231
|
const exts = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
|
|
203
232
|
for (const dir of pathEnv.split(path.delimiter)) {
|
|
@@ -208,6 +237,7 @@ export function whichCommand(name, env) {
|
|
|
208
237
|
const candidate = path.join(dir, name + ext);
|
|
209
238
|
try {
|
|
210
239
|
accessSync(candidate, constants.X_OK);
|
|
240
|
+
if (ignoreRealpath && realpathSync(candidate) === ignoreRealpath) continue;
|
|
211
241
|
return candidate;
|
|
212
242
|
} catch {
|
|
213
243
|
// try next
|
|
@@ -271,9 +301,9 @@ export function globalNpmEnv(env) {
|
|
|
271
301
|
|
|
272
302
|
export function globalWhobotEntry(prefix) {
|
|
273
303
|
if (process.platform === "win32") {
|
|
274
|
-
return path.join(prefix,
|
|
304
|
+
return path.join(prefix, `${COMMAND}.cmd`);
|
|
275
305
|
}
|
|
276
|
-
return path.join(prefix, "bin",
|
|
306
|
+
return path.join(prefix, "bin", COMMAND);
|
|
277
307
|
}
|
|
278
308
|
|
|
279
309
|
function spawnNpm(tools, args, env, timeoutMs) {
|
|
@@ -331,6 +361,7 @@ function npmInstallGlobalFrom(tools, spec, env) {
|
|
|
331
361
|
"--no-fund",
|
|
332
362
|
"--no-audit",
|
|
333
363
|
"--foreground-scripts",
|
|
364
|
+
"--install-links",
|
|
334
365
|
spec,
|
|
335
366
|
],
|
|
336
367
|
env,
|
|
@@ -368,7 +399,7 @@ function positionalCommands(argv) {
|
|
|
368
399
|
if (a === "--json") {
|
|
369
400
|
continue;
|
|
370
401
|
}
|
|
371
|
-
if (a === "-Q" || a === "--global-config" || a === "--channel") {
|
|
402
|
+
if (a === "-Q" || a === "--global-config" || a === "--channel" || a === "--target-version") {
|
|
372
403
|
i++;
|
|
373
404
|
continue;
|
|
374
405
|
}
|
|
@@ -387,14 +418,26 @@ function positionalCommands(argv) {
|
|
|
387
418
|
|
|
388
419
|
export function isUpdateApply(argv) {
|
|
389
420
|
const cmds = positionalCommands(argv);
|
|
390
|
-
return cmds[0] === "update" &&
|
|
421
|
+
return cmds.length === 1 && cmds[0] === "update" && argv.some((a) => a === "--yes" || a === "--yes=true");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function confirmedTargetVersion(argv) {
|
|
425
|
+
const targets = [];
|
|
426
|
+
for (let i = 0; i < argv.length; i++) {
|
|
427
|
+
if (argv[i] === "--target-version") targets.push(argv[++i]);
|
|
428
|
+
else if (argv[i].startsWith("--target-version=")) targets.push(argv[i].slice("--target-version=".length));
|
|
429
|
+
}
|
|
430
|
+
if (targets.length !== 1 || !STABLE_TARGET.test(targets[0] || "")) {
|
|
431
|
+
throw new InstallerError("请使用更新预览返回的含目标版本的 next.command", "UPDATE_VERIFY_FAILED");
|
|
432
|
+
}
|
|
433
|
+
return targets[0];
|
|
391
434
|
}
|
|
392
435
|
|
|
393
436
|
export function globalPackageDir(prefix, nodeOS = process.platform) {
|
|
394
437
|
if (nodeOS === "win32") {
|
|
395
|
-
return path.join(prefix, "node_modules", "
|
|
438
|
+
return path.join(prefix, "node_modules", ...pkg.name.split("/"));
|
|
396
439
|
}
|
|
397
|
-
return path.join(prefix, "lib", "node_modules", "
|
|
440
|
+
return path.join(prefix, "lib", "node_modules", ...pkg.name.split("/"));
|
|
398
441
|
}
|
|
399
442
|
|
|
400
443
|
export function packageNativeBinary(prefix, nodeOS = process.platform, nodeArch = process.arch) {
|
|
@@ -508,21 +551,6 @@ function readDistTags(tools, env) {
|
|
|
508
551
|
}
|
|
509
552
|
}
|
|
510
553
|
|
|
511
|
-
function readRegistryVersion(tools, env, channel) {
|
|
512
|
-
const spec = `${pkg.name}@${channel}`;
|
|
513
|
-
const result = spawnNpm(tools, ["view", spec, "version"], env, 30_000);
|
|
514
|
-
const ver = String(result.stdout || "").trim();
|
|
515
|
-
if (result.error) {
|
|
516
|
-
throw new InstallerError(`cannot resolve ${spec}: ${result.error.message}`);
|
|
517
|
-
}
|
|
518
|
-
if (result.status !== 0 || ver === "") {
|
|
519
|
-
throw new InstallerError(
|
|
520
|
-
`cannot resolve ${spec}: ${String(result.stderr || result.stdout || "").trim()}`,
|
|
521
|
-
);
|
|
522
|
-
}
|
|
523
|
-
return ver;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
554
|
function rejectExternalChannel(env) {
|
|
527
555
|
if (String(env.WHOBOT_CLI_CHANNEL || "").trim()) {
|
|
528
556
|
throw new InstallerError("发行渠道由发行包内置,不接受 WHOBOT_CLI_CHANNEL", "CHANNEL_INVALID");
|
|
@@ -536,6 +564,7 @@ function verifyNpmEntryVersion(entry, wantVersion, env) {
|
|
|
536
564
|
timeout: VERIFY_TIMEOUT_MS,
|
|
537
565
|
windowsHide: true,
|
|
538
566
|
stdio: ["ignore", "pipe", "pipe"],
|
|
567
|
+
shell: process.platform === "win32" && /\.(cmd|bat)$/i.test(entry),
|
|
539
568
|
});
|
|
540
569
|
const combined = `${result.stdout || ""}\n${result.stderr || ""}`;
|
|
541
570
|
if (result.error) {
|
|
@@ -572,9 +601,9 @@ function verifyNpmEntryVersion(entry, wantVersion, env) {
|
|
|
572
601
|
if (parsed.ok !== true) {
|
|
573
602
|
throw new InstallerError("version --json verification ok=false");
|
|
574
603
|
}
|
|
575
|
-
if (parsed.
|
|
604
|
+
if (parsed.meta?.protocol !== 2) {
|
|
576
605
|
throw new InstallerError(
|
|
577
|
-
`
|
|
606
|
+
`protocol ${parsed.meta?.protocol} is not 2`,
|
|
578
607
|
);
|
|
579
608
|
}
|
|
580
609
|
if (parsed.data?.version !== wantVersion) {
|
|
@@ -585,20 +614,56 @@ function verifyNpmEntryVersion(entry, wantVersion, env) {
|
|
|
585
614
|
return parsed;
|
|
586
615
|
}
|
|
587
616
|
|
|
617
|
+
async function repairAndVerifyNative(native, version, env) {
|
|
618
|
+
try {
|
|
619
|
+
verifyNpmEntryVersion(native, version, env);
|
|
620
|
+
} catch {
|
|
621
|
+
// npm registration can survive a missing, damaged or stale native file.
|
|
622
|
+
// Restore that exact package version, then verify the real executable.
|
|
623
|
+
await ensureNativeBinary(env, { force: true, dest: native, version });
|
|
624
|
+
verifyNpmEntryVersion(native, version, env);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export async function verifyInstalledCLI(tools, npmEnv, prefix, version, env, ignoreLauncher = "") {
|
|
629
|
+
const registered = readGlobalPackageVersion(tools, npmEnv);
|
|
630
|
+
if (registered !== version) {
|
|
631
|
+
throw new InstallerError(`npm registered version ${registered} does not match ${version}`, "UPDATE_VERIFY_FAILED");
|
|
632
|
+
}
|
|
633
|
+
const native = packageNativeBinary(prefix);
|
|
634
|
+
await repairAndVerifyNative(native, version, env);
|
|
635
|
+
const entry = globalWhobotEntry(prefix);
|
|
636
|
+
verifyNpmEntryVersion(entry, version, env);
|
|
637
|
+
const onPath = whichCommand(COMMAND, env, ignoreLauncher);
|
|
638
|
+
if (onPath && realpathSync(onPath) !== realpathSync(entry)) {
|
|
639
|
+
throw new InstallerError(`PATH resolves ${COMMAND} to another entry: ${onPath}; remove or replace it before using ${entry}`, "UPDATE_VERIFY_FAILED");
|
|
640
|
+
}
|
|
641
|
+
return { entry, native };
|
|
642
|
+
}
|
|
643
|
+
|
|
588
644
|
function parseExplicitInstallArgs(argv) {
|
|
589
645
|
const { forwarded } = parseChannelAndForward(argv);
|
|
646
|
+
let resetLocal = false;
|
|
590
647
|
for (const a of forwarded) {
|
|
648
|
+
if (a === "--reset-local") {
|
|
649
|
+
resetLocal = true;
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
591
652
|
if (a.startsWith("-")) {
|
|
592
653
|
throw new InstallerError(`unknown flag ${a}`);
|
|
593
654
|
}
|
|
594
655
|
throw new InstallerError(`unexpected argument ${a}`);
|
|
595
656
|
}
|
|
657
|
+
return { resetLocal };
|
|
596
658
|
}
|
|
597
659
|
|
|
598
660
|
export async function runExplicitInstall(argv, env) {
|
|
599
661
|
rejectExternalChannel(env);
|
|
600
|
-
parseExplicitInstallArgs(argv);
|
|
662
|
+
const { resetLocal } = parseExplicitInstallArgs(argv);
|
|
601
663
|
bakedChannel();
|
|
664
|
+
if (!STABLE_TARGET.test(pkg.version)) {
|
|
665
|
+
throw new InstallerError("安装目标必须是完整的稳定 SemVer 版本", "UPDATE_VERIFY_FAILED");
|
|
666
|
+
}
|
|
602
667
|
const tools = findRuntimeTools(env);
|
|
603
668
|
if (tools.missing.length) {
|
|
604
669
|
throw new InstallerError(runtimeMissingMessage(tools.missing));
|
|
@@ -607,20 +672,72 @@ export async function runExplicitInstall(argv, env) {
|
|
|
607
672
|
const prefix = readNpmGlobalPrefix(tools, npmEnv);
|
|
608
673
|
const entry = globalWhobotEntry(prefix);
|
|
609
674
|
const registered = readGlobalPackageVersion(tools, npmEnv);
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
675
|
+
logStderr(`install target ${pkg.name}@${pkg.version} (channel ${bakedChannel()})`);
|
|
676
|
+
// Validate with the new reader before replacing the installed program.
|
|
677
|
+
// Published v1 updaters cannot validate the v2 output protocol.
|
|
678
|
+
const staging = await fs.mkdtemp(path.join(os.tmpdir(), "whobot-install-preflight-"));
|
|
679
|
+
const stagedNative = path.join(staging, path.basename(packageNativeBinary(prefix)));
|
|
680
|
+
const updateArgs = ["update", "--yes", "--target-version", pkg.version];
|
|
681
|
+
let prepared;
|
|
682
|
+
try {
|
|
683
|
+
await ensureNativeBinary(env, { force: true, dest: stagedNative, version: pkg.version });
|
|
684
|
+
verifyNpmEntryVersion(stagedNative, pkg.version, env);
|
|
685
|
+
if (resetLocal) runResetLocal(stagedNative, env);
|
|
686
|
+
runInstallPhase(stagedNative, ["update", "check-install", "--previous-binary", packageNativeBinary(prefix)], env, "binding", registered);
|
|
687
|
+
runInstallPhase(stagedNative, updateArgs, env, "preflight", registered);
|
|
688
|
+
prepared = await prepareNativeReplace(packageNativeBinary(prefix));
|
|
689
|
+
if (registered !== pkg.version || !(await pathExists(entry))) {
|
|
690
|
+
logStderr(registered ? `installing global ${pkg.name}@${pkg.version} (was ${registered})` : `installing global ${pkg.name}@${pkg.version}`);
|
|
691
|
+
npmInstallGlobalFrom(tools, pkgRoot, npmEnv);
|
|
692
|
+
}
|
|
693
|
+
// Ignore this npx cache launcher only, not other stale host PATH entries.
|
|
694
|
+
const ignoreLauncher = isNpxPath(pkgRoot) ? realpathSync(path.join(pkgRoot, "bin", "run.mjs")) : "";
|
|
695
|
+
const installed = await verifyInstalledCLI(tools, npmEnv, prefix, pkg.version, env, ignoreLauncher);
|
|
696
|
+
runInstallPhase(installed.native, updateArgs, env, "verify", registered);
|
|
697
|
+
await prepared.discard();
|
|
698
|
+
if (resetLocal) logStderr("本机登录已清理,请重新登录。");
|
|
699
|
+
logStderr(`CLI 软件安装已完成;请更新 Skill 至对应渠道最新版,重载宿主或新建任务后重新预检。v2 后续更新请运行 ${COMMAND} update,并执行确认卡中的命令。`);
|
|
700
|
+
return entry;
|
|
701
|
+
} catch (err) {
|
|
702
|
+
if (prepared) await prepared.restore();
|
|
703
|
+
throw err;
|
|
704
|
+
} finally {
|
|
705
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
619
706
|
}
|
|
620
|
-
|
|
621
|
-
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function runInstallPhase(native, args, env, phase, previous, wantVersion = pkg.version) {
|
|
710
|
+
const result = spawnSync(native, args, {
|
|
711
|
+
encoding: "utf8",
|
|
712
|
+
env: { ...env, WHOBOT_UPDATE_PHASE: phase, WHOBOT_UPDATE_LOCK_PID: String(process.pid), WHOBOT_UPDATE_PREVIOUS_VERSION: previous || "" },
|
|
713
|
+
windowsHide: true,
|
|
714
|
+
});
|
|
715
|
+
if (result.error || result.signal) throw new InstallerError(`安装${phase}检查未完成: ${result.error?.message || result.signal}`, "UPDATE_VERIFY_FAILED");
|
|
716
|
+
let response;
|
|
717
|
+
try { response = JSON.parse(result.stdout); } catch { throw new InstallerError("安装检查没有返回合法 JSON", "UPDATE_VERIFY_FAILED"); }
|
|
718
|
+
if (result.status !== 0 || !response.ok || response.next?.action !== "done" || response.data?.installedVersion !== wantVersion) {
|
|
719
|
+
const code = response.error?.code || "UPDATE_VERIFY_FAILED";
|
|
720
|
+
// These two already name the recovery (install --reset-local, then sign in again).
|
|
721
|
+
const tail = RESET_LOCAL_CODES.has(code) ? "。" : ";请按上述原因处理后重试,不要删除客户端安装身份。";
|
|
722
|
+
throw new InstallerError(`安装未完成: ${code} ${response.error?.detail || response.error?.userMessage || "目标程序未通过验收"}${tail}`, "UPDATE_VERIFY_FAILED");
|
|
723
|
+
}
|
|
724
|
+
return response;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const RESET_LOCAL_CODES = new Set(["PREVIOUS_CLI_MISSING", "CREDENTIAL_ORIGIN_MISMATCH"]);
|
|
728
|
+
|
|
729
|
+
// install --reset-local: the verified target program clears this profile's
|
|
730
|
+
// local login before the binding check. Nothing is sent to any endpoint; the
|
|
731
|
+
// user signs in again after installing.
|
|
732
|
+
function runResetLocal(native, env) {
|
|
733
|
+
const result = spawnSync(native, ["reset", "--local-only", "--yes"], { encoding: "utf8", env, windowsHide: true });
|
|
734
|
+
if (result.error || result.signal) throw new InstallerError(`清理本机登录未完成: ${result.error?.message || result.signal}`, "UPDATE_VERIFY_FAILED");
|
|
735
|
+
let response;
|
|
736
|
+
try { response = JSON.parse(result.stdout); } catch { throw new InstallerError("清理本机登录没有返回合法 JSON", "UPDATE_VERIFY_FAILED"); }
|
|
737
|
+
if (result.status !== 0 || !response.ok || response.data?.scope !== "local") {
|
|
738
|
+
throw new InstallerError(`清理本机登录未完成: ${response.error?.code || "UPDATE_VERIFY_FAILED"} ${response.error?.detail || response.error?.userMessage || ""}`, "UPDATE_VERIFY_FAILED");
|
|
622
739
|
}
|
|
623
|
-
|
|
740
|
+
logStderr("cleared local login before install");
|
|
624
741
|
}
|
|
625
742
|
|
|
626
743
|
export async function runExplicitUpdate(argv, env) {
|
|
@@ -635,12 +752,22 @@ export async function runExplicitUpdate(argv, env) {
|
|
|
635
752
|
const entry = globalWhobotEntry(prefix);
|
|
636
753
|
const previous = readGlobalPackageVersion(tools, npmEnv);
|
|
637
754
|
const channel = bakedChannel();
|
|
638
|
-
const
|
|
639
|
-
const
|
|
755
|
+
const targetVersion = confirmedTargetVersion(argv);
|
|
756
|
+
const spec = `${pkg.name}@${targetVersion}`;
|
|
640
757
|
const replacing = previous !== targetVersion || !(await pathExists(entry));
|
|
641
758
|
const native = packageNativeBinary(prefix);
|
|
642
|
-
const
|
|
759
|
+
const staging = await fs.mkdtemp(path.join(os.tmpdir(), "whobot-update-target-"));
|
|
760
|
+
let prepared;
|
|
643
761
|
try {
|
|
762
|
+
const targetNative = path.join(staging, path.basename(native));
|
|
763
|
+
await ensureNativeBinary(env, { force: true, dest: targetNative, version: targetVersion });
|
|
764
|
+
const checkArgs = ["update", "check-install", "--previous-binary", native];
|
|
765
|
+
for (let i = 0; i < argv.length; i++) {
|
|
766
|
+
if (argv[i] === "--global-config" || argv[i] === "-Q") checkArgs.push(argv[i], argv[++i]);
|
|
767
|
+
else if (argv[i].startsWith("--global-config=") || argv[i].startsWith("-Q=")) checkArgs.push(argv[i]);
|
|
768
|
+
}
|
|
769
|
+
runInstallPhase(targetNative, checkArgs, env, "binding", previous, targetVersion);
|
|
770
|
+
prepared = replacing ? await prepareNativeReplace(native) : { restore: async () => {}, discard: async () => {} };
|
|
644
771
|
if (replacing) {
|
|
645
772
|
logStderr(
|
|
646
773
|
previous
|
|
@@ -654,18 +781,7 @@ export async function runExplicitUpdate(argv, env) {
|
|
|
654
781
|
if (!(await pathExists(entry))) {
|
|
655
782
|
throw new InstallerError(`global command entry is missing: ${entry}`);
|
|
656
783
|
}
|
|
657
|
-
|
|
658
|
-
try {
|
|
659
|
-
await ensureNativeBinary(env, { force: true, dest: native, version: targetVersion });
|
|
660
|
-
} catch (err) {
|
|
661
|
-
const message = err && err.message ? err.message : String(err);
|
|
662
|
-
throw new InstallerError(`更新后未找到原生 CLI: ${message}`, "UPDATE_VERIFY_FAILED");
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
if (!(await isRegularFile(native))) {
|
|
666
|
-
throw new InstallerError(`更新后未找到原生 CLI: ${native}`, "UPDATE_VERIFY_FAILED");
|
|
667
|
-
}
|
|
668
|
-
verifyNpmEntryVersion(native, targetVersion, env);
|
|
784
|
+
await verifyInstalledCLI(tools, npmEnv, prefix, targetVersion, env);
|
|
669
785
|
await prepared.discard();
|
|
670
786
|
return {
|
|
671
787
|
entry,
|
|
@@ -676,19 +792,18 @@ export async function runExplicitUpdate(argv, env) {
|
|
|
676
792
|
channel,
|
|
677
793
|
};
|
|
678
794
|
} catch (err) {
|
|
679
|
-
await prepared.restore();
|
|
795
|
+
if (prepared) await prepared.restore();
|
|
680
796
|
throw err;
|
|
797
|
+
} finally {
|
|
798
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
681
799
|
}
|
|
682
800
|
}
|
|
683
801
|
|
|
684
802
|
function launcherExitCode(code) {
|
|
685
803
|
if (code === "CHANNEL_INVALID") {
|
|
686
|
-
return
|
|
687
|
-
}
|
|
688
|
-
if (code === "UPDATE_VERIFY_FAILED") {
|
|
689
|
-
return 77;
|
|
804
|
+
return 2;
|
|
690
805
|
}
|
|
691
|
-
return
|
|
806
|
+
return 7;
|
|
692
807
|
}
|
|
693
808
|
|
|
694
809
|
function reportNativeFailure(dest, spawnErr, { jsonFailure = false } = {}) {
|
|
@@ -697,7 +812,7 @@ function reportNativeFailure(dest, spawnErr, { jsonFailure = false } = {}) {
|
|
|
697
812
|
: `in-package binary cannot be executed: ${dest}`;
|
|
698
813
|
if (jsonFailure) {
|
|
699
814
|
writeJSONFailure("UPDATE_VERIFY_FAILED", message);
|
|
700
|
-
process.exitCode =
|
|
815
|
+
process.exitCode = 7;
|
|
701
816
|
} else {
|
|
702
817
|
process.stderr.write(`${message}\n`);
|
|
703
818
|
process.exitCode = 1;
|
|
@@ -744,22 +859,38 @@ export async function runWhobot(argv, env) {
|
|
|
744
859
|
try {
|
|
745
860
|
rejectExternalChannel(env);
|
|
746
861
|
parseChannelAndForward(argv);
|
|
862
|
+
confirmedTargetVersion(argv);
|
|
747
863
|
let currentNative = nativeBinaryPath(pkgRoot);
|
|
748
|
-
|
|
749
|
-
|
|
864
|
+
let preflightDir;
|
|
865
|
+
let pre;
|
|
866
|
+
try {
|
|
867
|
+
try {
|
|
868
|
+
verifyNpmEntryVersion(currentNative, pkg.version, env);
|
|
869
|
+
} catch {
|
|
870
|
+
// A broken current program cannot check activity. Run a verified
|
|
871
|
+
// copy from temporary storage without replacing the installed file.
|
|
872
|
+
preflightDir = await fs.mkdtemp(path.join(os.tmpdir(), "whobot-preflight-"));
|
|
873
|
+
currentNative = path.join(preflightDir, path.basename(currentNative));
|
|
874
|
+
await ensureNativeBinary(env, { force: true, dest: currentNative, version: pkg.version });
|
|
875
|
+
}
|
|
876
|
+
pre = spawnSync(currentNative, argv, {
|
|
877
|
+
encoding: "utf8",
|
|
878
|
+
env: {
|
|
879
|
+
...env,
|
|
880
|
+
WHOBOT_UPDATE_PHASE: "preflight",
|
|
881
|
+
WHOBOT_UPDATE_LOCK_PID: String(process.pid),
|
|
882
|
+
},
|
|
883
|
+
windowsHide: false,
|
|
884
|
+
});
|
|
885
|
+
} finally {
|
|
886
|
+
if (preflightDir) await fs.rm(preflightDir, { recursive: true, force: true });
|
|
750
887
|
}
|
|
751
|
-
const pre = spawnSync(currentNative, argv, {
|
|
752
|
-
encoding: "utf8",
|
|
753
|
-
env: {
|
|
754
|
-
...env,
|
|
755
|
-
WHOBOT_UPDATE_PHASE: "preflight",
|
|
756
|
-
WHOBOT_UPDATE_LOCK_PID: String(process.pid),
|
|
757
|
-
},
|
|
758
|
-
windowsHide: false,
|
|
759
|
-
});
|
|
760
888
|
if (pre.stderr) {
|
|
761
889
|
process.stderr.write(pre.stderr);
|
|
762
890
|
}
|
|
891
|
+
if (pre.error || pre.signal) {
|
|
892
|
+
throw new InstallerError(`更新前检查未完成: ${pre.error?.message || pre.signal}`, "UPDATE_VERIFY_FAILED");
|
|
893
|
+
}
|
|
763
894
|
if (pre.status !== 0) {
|
|
764
895
|
if (pre.stdout) {
|
|
765
896
|
process.stdout.write(pre.stdout);
|
|
@@ -767,6 +898,10 @@ export async function runWhobot(argv, env) {
|
|
|
767
898
|
process.exitCode = pre.status ?? 1;
|
|
768
899
|
return;
|
|
769
900
|
}
|
|
901
|
+
const preflight = JSON.parse(pre.stdout);
|
|
902
|
+
if (!preflight.ok || preflight.data?.installedVersion !== pkg.version || preflight.next?.action !== "done") {
|
|
903
|
+
throw new InstallerError("更新前检查没有确认可以替换程序", "UPDATE_VERIFY_FAILED");
|
|
904
|
+
}
|
|
770
905
|
const updated = await runExplicitUpdate(argv, env);
|
|
771
906
|
spawnNative(
|
|
772
907
|
updated.native,
|
|
@@ -780,7 +915,7 @@ export async function runWhobot(argv, env) {
|
|
|
780
915
|
{ jsonFailure: true },
|
|
781
916
|
);
|
|
782
917
|
} catch (err) {
|
|
783
|
-
const code = err
|
|
918
|
+
const code = err?.code === "CHANNEL_INVALID" ? "CHANNEL_INVALID" : "UPDATE_VERIFY_FAILED";
|
|
784
919
|
const message = err && err.message ? err.message : String(err);
|
|
785
920
|
writeJSONFailure(code, message);
|
|
786
921
|
process.stderr.write(`${message}\n`);
|
|
@@ -832,7 +967,7 @@ export async function runInstall(argv, env) {
|
|
|
832
967
|
smoke(smokeStage, version, home, env);
|
|
833
968
|
await replaceAtomically(smokeStage, dest);
|
|
834
969
|
smoke(dest, version, home, env);
|
|
835
|
-
await fs.writeFile(path.join(home, ".installer-managed"),
|
|
970
|
+
await fs.writeFile(path.join(home, ".installer-managed"), `npx ${pkg.name}\n`, {
|
|
836
971
|
encoding: "utf8",
|
|
837
972
|
mode: 0o644,
|
|
838
973
|
});
|
|
@@ -924,9 +1059,9 @@ export function resolveHome(env, platform) {
|
|
|
924
1059
|
}
|
|
925
1060
|
const home = lookupUserHome(env, platform);
|
|
926
1061
|
if (!home) {
|
|
927
|
-
return
|
|
1062
|
+
return HOME_NAME;
|
|
928
1063
|
}
|
|
929
|
-
return path.join(home,
|
|
1064
|
+
return path.join(home, HOME_NAME);
|
|
930
1065
|
}
|
|
931
1066
|
|
|
932
1067
|
function lookupUserHome(env, platform) {
|
|
@@ -997,7 +1132,7 @@ export async function resolveInstallHome(env, platform, cwd) {
|
|
|
997
1132
|
return path.resolve(preferred);
|
|
998
1133
|
}
|
|
999
1134
|
}
|
|
1000
|
-
const fallback = path.resolve(cwd,
|
|
1135
|
+
const fallback = path.resolve(cwd, HOME_NAME);
|
|
1001
1136
|
if (await dirWritable(path.join(fallback, "bin"))) {
|
|
1002
1137
|
return await fs.realpath(fallback);
|
|
1003
1138
|
}
|
|
@@ -1129,7 +1264,7 @@ function normalizeArtifact(raw, known) {
|
|
|
1129
1264
|
|
|
1130
1265
|
function archiveFileName(version, osName, arch) {
|
|
1131
1266
|
const ext = osName === "windows" ? ".zip" : ".tar.gz";
|
|
1132
|
-
return
|
|
1267
|
+
return `${COMMAND}_${version}_${osName}_${arch}${ext}`;
|
|
1133
1268
|
}
|
|
1134
1269
|
|
|
1135
1270
|
async function downloadFile(url, dest, expectedSize, expectedSHA, env) {
|
|
@@ -1449,9 +1584,9 @@ function smoke(binPath, version, home, env) {
|
|
|
1449
1584
|
if (parsed.ok !== true) {
|
|
1450
1585
|
throw new InstallerError("version smoke ok=false");
|
|
1451
1586
|
}
|
|
1452
|
-
if (parsed.
|
|
1587
|
+
if (parsed.meta?.protocol !== 2) {
|
|
1453
1588
|
throw new InstallerError(
|
|
1454
|
-
`
|
|
1589
|
+
`protocol ${parsed.meta?.protocol} is not 2`,
|
|
1455
1590
|
);
|
|
1456
1591
|
}
|
|
1457
1592
|
if (parsed.data?.version !== version) {
|
package/bootstrap.json
CHANGED