@whobot/cli 1.0.1 → 2.0.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/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
- whobot update apply --json
21
- # 或
22
- npx --yes @whobot/cli@latest install
20
+ npx --yes @whobot/cli@2.0.0 install
23
21
  ```
24
22
 
25
- 更新走 npm,不向数据 Home/bin 写入程序文件。
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 = "whobot";
34
- const BINARY_WINDOWS = "whobot.exe";
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: { code, message, retryable: false },
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
- cliVersion: pkg.version,
76
- apiVersion: "v1",
77
- protocolVersion: 1,
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(`whobot: ${message}\n`);
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, "whobot.cmd");
304
+ return path.join(prefix, `${COMMAND}.cmd`);
275
305
  }
276
- return path.join(prefix, "bin", "whobot");
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" && cmds[1] === "apply";
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", "@whobot", "cli");
438
+ return path.join(prefix, "node_modules", ...pkg.name.split("/"));
396
439
  }
397
- return path.join(prefix, "lib", "node_modules", "@whobot", "cli");
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.data?.protocolVersion !== 1) {
604
+ if (parsed.meta?.protocol !== 2) {
576
605
  throw new InstallerError(
577
- `protocolVersion ${parsed.data?.protocolVersion} is not 1`,
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
- if (registered !== pkg.version || !(await pathExists(entry))) {
611
- logStderr(
612
- registered
613
- ? `installing global ${pkg.name}@${pkg.version} (was ${registered})`
614
- : `installing global ${pkg.name}@${pkg.version}`,
615
- );
616
- npmInstallGlobalFrom(tools, pkgRoot, npmEnv);
617
- } else {
618
- logStderr(`global ${pkg.name}@${pkg.version} already registered`);
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
- if (!(await pathExists(entry))) {
621
- throw new InstallerError(`global command entry is missing: ${entry}`);
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
- return entry;
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 spec = `${pkg.name}@${channel}`;
639
- const targetVersion = readRegistryVersion(tools, npmEnv, channel);
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 prepared = replacing ? await prepareNativeReplace(native) : { restore: async () => {}, discard: async () => {} };
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
- if (!(await isRegularFile(native))) {
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 76;
687
- }
688
- if (code === "UPDATE_VERIFY_FAILED") {
689
- return 77;
804
+ return 2;
690
805
  }
691
- return 1;
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 = 77;
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
- if (!(await isRegularFile(currentNative))) {
749
- currentNative = await ensureNativeBinary(env);
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 && err.code ? err.code : "INTERNAL";
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"), "npx @whobot/cli\n", {
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 WORKSPACE_HOME_NAME;
1062
+ return HOME_NAME;
928
1063
  }
929
- return path.join(home, WORKSPACE_HOME_NAME);
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, WORKSPACE_HOME_NAME);
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 `whobot_${version}_${osName}_${arch}${ext}`;
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.data?.protocolVersion !== 1) {
1587
+ if (parsed.meta?.protocol !== 2) {
1453
1588
  throw new InstallerError(
1454
- `protocolVersion ${parsed.data?.protocolVersion} is not 1`,
1589
+ `protocol ${parsed.meta?.protocol} is not 2`,
1455
1590
  );
1456
1591
  }
1457
1592
  if (parsed.data?.version !== version) {
package/bootstrap.json CHANGED
@@ -2,6 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "channel": "latest",
4
4
  "downloadBase": "https://download.whobot.com/agent-cli",
5
+ "homeName": ".whobot-cli",
5
6
  "knownOs": [
6
7
  "darwin",
7
8
  "linux",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whobot/cli",
3
- "version": "1.0.1",
3
+ "version": "2.0.1",
4
4
  "description": "Whobot CLI: npm-managed JS launcher that runs the in-package Go binary.",
5
5
  "type": "module",
6
6
  "bin": {