@whobot/cli 2.0.2 → 2.0.4

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.
Files changed (2) hide show
  1. package/bin/install.mjs +77 -6
  2. package/package.json +1 -1
package/bin/install.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import {
5
5
  accessSync,
@@ -226,7 +226,38 @@ export async function runPostinstall(env) {
226
226
  }
227
227
  }
228
228
 
229
- export function whichCommand(name, env, ignoreRealpath = "") {
229
+ // npxLauncherIgnore names the command entries of the npx copy at root, which
230
+ // npx puts first on PATH. POSIX npm links them to run.mjs; Windows npm writes
231
+ // cmd-shim files into the same node_modules/.bin, so a realpath alone cannot
232
+ // recognize them. Outside an npx copy nothing is ignored.
233
+ export function npxLauncherIgnore(root) {
234
+ if (!isNpxPath(root)) {
235
+ return {};
236
+ }
237
+ const ignore = { realpath: realpathSync(path.join(root, "bin", "run.mjs")) };
238
+ let modules = root;
239
+ while (path.basename(modules) !== "node_modules") {
240
+ const parent = path.dirname(modules);
241
+ if (parent === modules) {
242
+ return ignore;
243
+ }
244
+ modules = parent;
245
+ }
246
+ const binDir = path.join(modules, ".bin");
247
+ try {
248
+ ignore.binDir = realpathSync(binDir);
249
+ } catch {
250
+ // No .bin directory: no PATH entry can be inside it.
251
+ ignore.binDir = binDir;
252
+ }
253
+ return ignore;
254
+ }
255
+
256
+ function samePath(a, b) {
257
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
258
+ }
259
+
260
+ export function whichCommand(name, env, ignore = {}) {
230
261
  const pathEnv = env.PATH || env.Path || "";
231
262
  const exts = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
232
263
  for (const dir of pathEnv.split(path.delimiter)) {
@@ -237,7 +268,8 @@ export function whichCommand(name, env, ignoreRealpath = "") {
237
268
  const candidate = path.join(dir, name + ext);
238
269
  try {
239
270
  accessSync(candidate, constants.X_OK);
240
- if (ignoreRealpath && realpathSync(candidate) === ignoreRealpath) continue;
271
+ if (ignore.realpath && realpathSync(candidate) === ignore.realpath) continue;
272
+ if (ignore.binDir && samePath(realpathSync(dir), ignore.binDir)) continue;
241
273
  return candidate;
242
274
  } catch {
243
275
  // try next
@@ -625,7 +657,7 @@ async function repairAndVerifyNative(native, version, env) {
625
657
  }
626
658
  }
627
659
 
628
- export async function verifyInstalledCLI(tools, npmEnv, prefix, version, env, ignoreLauncher = "") {
660
+ export async function verifyInstalledCLI(tools, npmEnv, prefix, version, env, ignoreLauncher = {}) {
629
661
  const registered = readGlobalPackageVersion(tools, npmEnv);
630
662
  if (registered !== version) {
631
663
  throw new InstallerError(`npm registered version ${registered} does not match ${version}`, "UPDATE_VERIFY_FAILED");
@@ -678,9 +710,14 @@ export async function runExplicitInstall(argv, env) {
678
710
  const staging = await fs.mkdtemp(path.join(os.tmpdir(), "whobot-install-preflight-"));
679
711
  const stagedNative = path.join(staging, path.basename(packageNativeBinary(prefix)));
680
712
  const updateArgs = ["update", "--yes", "--target-version", pkg.version];
713
+ const installId = randomUUID();
714
+ const reportAttrs = { installId, version: pkg.version, channel: bakedChannel(), previousVersion: registered };
715
+ let staged = false;
681
716
  let prepared;
682
717
  try {
683
718
  await ensureNativeBinary(env, { force: true, dest: stagedNative, version: pkg.version });
719
+ staged = true;
720
+ reportViaNative(stagedNative, "install_started", "ok", { attrs: reportAttrs }, env);
684
721
  verifyNpmEntryVersion(stagedNative, pkg.version, env);
685
722
  if (resetLocal) runResetLocal(stagedNative, env);
686
723
  runInstallPhase(stagedNative, ["update", "check-install", "--previous-binary", packageNativeBinary(prefix)], env, "binding", registered);
@@ -691,21 +728,47 @@ export async function runExplicitInstall(argv, env) {
691
728
  npmInstallGlobalFrom(tools, pkgRoot, npmEnv);
692
729
  }
693
730
  // Ignore this npx cache launcher only, not other stale host PATH entries.
694
- const ignoreLauncher = isNpxPath(pkgRoot) ? realpathSync(path.join(pkgRoot, "bin", "run.mjs")) : "";
731
+ const ignoreLauncher = npxLauncherIgnore(pkgRoot);
695
732
  const installed = await verifyInstalledCLI(tools, npmEnv, prefix, pkg.version, env, ignoreLauncher);
696
733
  runInstallPhase(installed.native, updateArgs, env, "verify", registered);
697
734
  await prepared.discard();
735
+ reportViaNative(installed.native, "install_succeeded", "ok", { attrs: reportAttrs }, env);
698
736
  if (resetLocal) logStderr("本机登录已清理,请重新登录。");
699
737
  logStderr(`CLI 软件安装已完成;请更新 Skill 至对应渠道最新版,重载宿主或新建任务后重新预检。v2 后续更新请运行 ${COMMAND} update,并执行确认卡中的命令。`);
700
738
  return entry;
701
739
  } catch (err) {
702
740
  if (prepared) await prepared.restore();
741
+ // Before the program is staged there is nothing to report with.
742
+ if (staged) {
743
+ reportViaNative(stagedNative, "install_failed", "failed", { errorCode: failureCode(err) || "INSTALL_FAILED", attrs: reportAttrs }, env);
744
+ }
703
745
  throw err;
704
746
  } finally {
705
747
  await fs.rm(staging, { recursive: true, force: true });
706
748
  }
707
749
  }
708
750
 
751
+ // reportViaNative records an install/update fact for analytics (ADR-0020)
752
+ // through the native program's hidden reporter, which carries its compiled API
753
+ // address, client id and AI host. Its outcome is ignored and it is bounded, so
754
+ // it never fails or stalls an install or update.
755
+ const REPORT_TIMEOUT_MS = 5000;
756
+ export function reportViaNative(native, name, result, { errorCode = "", attrs = {} } = {}, env = process.env) {
757
+ const args = ["telemetry", "report", "--name", name, "--result", result];
758
+ if (errorCode) args.push("--error-code", errorCode);
759
+ for (const [key, value] of Object.entries(attrs)) {
760
+ if (value !== undefined && value !== null && value !== "") args.push("--attr", `${key}=${value}`);
761
+ }
762
+ const reportEnv = { ...env };
763
+ delete reportEnv.WHOBOT_UPDATE_PHASE;
764
+ spawnSync(native, args, { env: reportEnv, stdio: "ignore", timeout: REPORT_TIMEOUT_MS, windowsHide: true });
765
+ }
766
+
767
+ function failureCode(err) {
768
+ const code = err && typeof err.code === "string" ? err.code : "";
769
+ return /^[A-Z][A-Z0-9_]{0,63}$/.test(code) ? code : "";
770
+ }
771
+
709
772
  function runInstallPhase(native, args, env, phase, previous, wantVersion = pkg.version) {
710
773
  const result = spawnSync(native, args, {
711
774
  encoding: "utf8",
@@ -856,10 +919,12 @@ export async function runWhobot(argv, env) {
856
919
  }
857
920
  await recoverNativeBackup(nativeBinaryPath(pkgRoot));
858
921
  if (isUpdateApply(argv)) {
922
+ const updateAttrs = { updateId: randomUUID(), fromVersion: pkg.version };
923
+ let started = false;
859
924
  try {
860
925
  rejectExternalChannel(env);
861
926
  parseChannelAndForward(argv);
862
- confirmedTargetVersion(argv);
927
+ updateAttrs.toVersion = confirmedTargetVersion(argv);
863
928
  let currentNative = nativeBinaryPath(pkgRoot);
864
929
  let preflightDir;
865
930
  let pre;
@@ -902,7 +967,10 @@ export async function runWhobot(argv, env) {
902
967
  if (!preflight.ok || preflight.data?.installedVersion !== pkg.version || preflight.next?.action !== "done") {
903
968
  throw new InstallerError("更新前检查没有确认可以替换程序", "UPDATE_VERIFY_FAILED");
904
969
  }
970
+ reportViaNative(nativeBinaryPath(pkgRoot), "update_started", "ok", { attrs: updateAttrs }, env);
971
+ started = true;
905
972
  const updated = await runExplicitUpdate(argv, env);
973
+ reportViaNative(updated.native, "update_succeeded", "replaced", { attrs: { ...updateAttrs, fromVersion: updated.previous, toVersion: updated.installedVersion } }, env);
906
974
  spawnNative(
907
975
  updated.native,
908
976
  updated.forwarded,
@@ -916,6 +984,9 @@ export async function runWhobot(argv, env) {
916
984
  );
917
985
  } catch (err) {
918
986
  const code = err?.code === "CHANNEL_INVALID" ? "CHANNEL_INVALID" : "UPDATE_VERIFY_FAILED";
987
+ if (started) {
988
+ reportViaNative(nativeBinaryPath(pkgRoot), "update_failed", "failed", { errorCode: failureCode(err) || code, attrs: updateAttrs }, env);
989
+ }
919
990
  const message = err && err.message ? err.message : String(err);
920
991
  writeJSONFailure(code, message);
921
992
  process.stderr.write(`${message}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whobot/cli",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "Whobot CLI: npm-managed JS launcher that runs the in-package Go binary.",
5
5
  "type": "module",
6
6
  "bin": {