tmuxclihook 0.5.0 → 0.5.2

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
@@ -9,9 +9,12 @@
9
9
  npm install -g tmuxclihook
10
10
  tmuxclihook
11
11
 
12
- # 检查 npm registry 并将全局安装更新到最新版
12
+ # 按当前安装方式(npm/volta)检查并更新到最新版
13
13
  tmuxclihook update
14
14
 
15
+ # 删除不生效的另一份安装;已是最新时也会执行清理(默认交互确认)
16
+ tmuxclihook update --prune
17
+
15
18
  # hooks 健康明细(非交互):每个 CLI 的完整性、判定真源、注册路径、漂移警告
16
19
  # 可选参数 claude|codex|opencode 只看一个;exit 1 = 有缺失或漂移
17
20
  tmuxclihook check
@@ -34,9 +37,11 @@ npm start
34
37
 
35
38
  hooks 安装、tmux 集成写入的路径由「source」决定:环境变量 `TMUXCLIHOOK_SOURCE` > 持久化配置 `~/.config/tmuxclihook/config.json`(TUI「source 管理」菜单写入)> 包内自带 scripts/(生产默认)> 仓库根 scripts/(`cd installer && npm start` 时的开发回落)。见下方「source 管理」一节。
36
39
 
37
- `tmuxclihook update` 会显示当前版本,通过 `npm view tmuxclihook version` 查询最新版;版本不同时直接执行 `npm install -g tmuxclihook@latest`,版本相同则不做修改。网络查询或安装失败时命令以非零状态退出。
40
+ `tmuxclihook update` 会显示当前版本,通过 `npm view tmuxclihook version` 查询最新版;版本相同则不做任何安装(若显式传入 `--prune`,仍会执行一次非生效副本检测与清理)。需要升级时,它按 PATH 上第一个可执行的 `tmuxclihook` 判断当前生效的安装方式:入口位于 `$VOLTA_HOME/bin/`(默认 `~/.volta/bin/`)或 volta 的 packages 镜像内时,只执行 `volta install tmuxclihook@latest`;其余入口(npm 全局、nvm/fnm 等 node 工具链目录)只执行 `npm install -g tmuxclihook@latest`。不会同时写入两套安装。网络查询或安装失败时命令以非零状态退出。
41
+
42
+ 安装完成后会按 PATH 自检实际生效版本。若生效入口不是 volta、自检仍未反映新版,且本机可执行 volta,则补执行一次 `volta install tmuxclihook@latest` 作为兜底并复检;仍不一致时打印警告(不影响退出码)。
38
43
 
39
- 安装成功后会做一次生效自检:按 PATH 解析实际会被运行的 `tmuxclihook`,读取其真实版本与最新版比对。若本机使用 volta,`npm install -g` 只会更新 node 工具链目录里的副本、不会更新 volta 的 shim 镜像(实测 volta 2.0.2:shim npm 的全局 prefix 即工具链目录),自检发现这种情况时会自动补执行 `volta install tmuxclihook@latest` 使更新生效并复检;volta 缺失或修正失败时打警告并提示手动命令。`npm install` 始终是标准安装方式,无需因 volta 改变习惯。
44
+ 升级成功,或版本已是最新且显式传入 `--prune` 时,update 会检查另一套安装(volta packages 镜像或当前 npm 全局 prefix)是否还留有副本。发现副本时:TTY 下询问是否删除(默认不删);非交互环境只打印警告;`--prune` 跳过询问直接删除。删除前会校验该副本未被任何 hooks 与 `~/.tmux.conf` 引用——任一检查无法确认就保留并提示先重装 hooks。**生效入口永远不删**,清理失败不影响升级结果。也可以手动收敛:卸载不再使用的那份(`npm uninstall -g tmuxclihook` 或 `volta uninstall tmuxclihook`),再从生效入口重新安装 hooks。
40
45
 
41
46
  ## 功能
42
47
 
package/bin/cli.js CHANGED
@@ -31,6 +31,7 @@ import { setSource, clearSource, getPersistedSource, originLabel } from '../src/
31
31
  import { enableEscapeCancel, setSpinnerActive } from '../src/esc.js';
32
32
  import { note, padDisplay } from '../src/tui.js';
33
33
  import { updatePackage, CURRENT_VERSION } from '../src/update.js';
34
+ import { HELP_TEXT } from '../src/help.js';
34
35
 
35
36
  const OK = '✓';
36
37
  const NO = '✗';
@@ -491,7 +492,7 @@ async function runCheckCommand(only) {
491
492
 
492
493
  const arg = process.argv[2];
493
494
  if (arg === 'update') {
494
- updatePackage().then((ok) => {
495
+ updatePackage({ prune: process.argv.includes('--prune') }).then((ok) => {
495
496
  if (!ok) process.exitCode = 1;
496
497
  }).catch((err) => {
497
498
  console.error(err);
@@ -505,16 +506,7 @@ if (arg === 'update') {
505
506
  } else if (arg === '--version' || arg === '-v' || arg === 'version') {
506
507
  console.log(`tmuxclihook v${CURRENT_VERSION}`);
507
508
  } else if (arg === '--help' || arg === '-h' || arg === 'help') {
508
- console.log(
509
- 'tmuxclihook — tmux-ai-status 交互式安装器\n' +
510
- '\n' +
511
- '用法:\n' +
512
- ' tmuxclihook 打开交互式 TUI 安装器\n' +
513
- ' tmuxclihook check hooks 健康明细(可跟 claude|codex|opencode;exit 1 = 有缺失或漂移)\n' +
514
- ' tmuxclihook update 检查并更新到 npm 最新版本\n' +
515
- ' tmuxclihook version 显示版本(--version / -v 等价)\n' +
516
- ' tmuxclihook help 显示本帮助(--help / -h 等价)',
517
- );
509
+ console.log(HELP_TEXT);
518
510
  } else {
519
511
  main().catch((err) => {
520
512
  console.error(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmuxclihook",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "交互式 TUI 安装器:环境侦测、AI CLI 侦测、tmux-ai-status hooks 装/卸/修、TPM 软链校验",
5
5
  "type": "module",
6
6
  "bin": {
package/src/help.js ADDED
@@ -0,0 +1,31 @@
1
+ // `tmuxclihook help` 的静态文案。独立成模块是为了让测试可以直接 import,
2
+ // 而不必加载 bin/cli.js(入口文件顶层会执行参数分支,未知参数会启动 TUI)。
3
+ const COMMAND_WIDTH = 26;
4
+
5
+ function line(command, description) {
6
+ return ` ${command.padEnd(COMMAND_WIDTH)} ${description}`;
7
+ }
8
+
9
+ export const HELP_TEXT = [
10
+ 'tmuxclihook — tmux-ai-status 交互式安装器',
11
+ '',
12
+ '命令行:',
13
+ line('tmuxclihook', '打开交互式 TUI 安装器'),
14
+ line('tmuxclihook check', 'hooks 健康明细(可跟 claude|codex|opencode;exit 1 = 有缺失或漂移)'),
15
+ line('tmuxclihook update', '按当前安装方式(npm/volta)检查并更新到最新版本'),
16
+ line('tmuxclihook update --prune', '更新后删除不生效的另一份安装;已是最新时也会执行清理'),
17
+ line('tmuxclihook version', '显示版本(--version / -v 等价)'),
18
+ line('tmuxclihook help', '显示本帮助(--help / -h 等价)'),
19
+ '',
20
+ 'tmux 快捷键(插件加载后生效):',
21
+ line('prefix + I', '打开交互式 TUI 安装器'),
22
+ line('prefix + C-h', '安装 Claude Code hooks'),
23
+ line('prefix + C-u', '卸载 Claude Code hooks'),
24
+ line('prefix + M-h', '安装 Codex hooks'),
25
+ line('prefix + M-u', '卸载 Codex hooks'),
26
+ line('prefix + C-p', '安装 opencode plugin'),
27
+ line('prefix + M-p', '卸载 opencode plugin'),
28
+ line('prefix + r', '重载配置(含本插件初始化)'),
29
+ '',
30
+ 'prefix 默认是 Ctrl+b;若你在 tmux 配置中改过 prefix,以你的配置为准。',
31
+ ].join('\n');
package/src/update.js CHANGED
@@ -1,5 +1,6 @@
1
- import { readFileSync, accessSync, constants as fsConstants, realpathSync } from 'node:fs';
2
- import { delimiter, dirname, join } from 'node:path';
1
+ import { readFileSync, accessSync, existsSync, constants as fsConstants, realpathSync } from 'node:fs';
2
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import { execa } from 'execa';
4
5
 
5
6
  const PACKAGE_NAME = 'tmuxclihook';
@@ -9,12 +10,13 @@ export const CURRENT_VERSION = packageJson.version;
9
10
 
10
11
  // —— 生效版本自检 ————————————————————————————————————————————————
11
12
  //
12
- // `npm install -g` 在版本管理器(volta 等)环境下不会更新其管理的 shim:
13
- // volta 的 `~/.volta/bin` shim 指向 packages/ 镜像,而 npm -g 只写 node
14
- // 工具链目录,两者互不感知(实测 0.3.0 → 0.3.2 升级时用户命令仍解析到
15
- // 旧版)。生效收敛由 repairWithVolta 无条件完成(见下);本节自检只是
16
- // 事后确认:按 update 进程的 PATH 解析一份,尽力读取真实版本并与
17
- // registry latest 比对,无法确认或版本不符时打警告。
13
+ // `npm install -g` 在版本管理器(volta 等)环境下只写 node 工具链目录,
14
+ // 不更新 volta 的 shim 镜像(shim 指向 packages/)。因此升级前先按 PATH
15
+ // 上第一个可执行 tmuxclihook 判断「当前生效入口」属于哪套安装机制,只走
16
+ // 对应那一套(见 detectInstallSource / updatePackage),避免同一版本被写
17
+ // 成两份、hooks 指向其中一份而 PATH 命中另一份造成漂移。
18
+ // 本节自检只是事后确认:按 update 进程的 PATH 解析一份,尽力读取真实版本
19
+ // 并与 registry latest 比对,无法确认或版本不符时打警告。
18
20
  // 验证是尽力而为的提示,不改变 update 的返回值(install 本身已成功)。
19
21
 
20
22
  function findOnPath(name, env) {
@@ -31,6 +33,200 @@ function findOnPath(name, env) {
31
33
  return null;
32
34
  }
33
35
 
36
+ // realpath 失败时退回字面绝对路径,保证包含判断仍可进行(不抛)。
37
+ function realOrResolved(p) {
38
+ if (!p) return null;
39
+ try {
40
+ return realpathSync(p);
41
+ } catch {
42
+ return resolve(p);
43
+ }
44
+ }
45
+
46
+ // child 是否位于 parent 目录内(含相等)。用 relative 而非 startsWith,
47
+ // 避免 `$VOLTA_HOME/bin-other` 被误判为 `$VOLTA_HOME/bin` 的子路径。
48
+ function isPathInside(child, parent) {
49
+ const rel = relative(parent, child);
50
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
51
+ }
52
+
53
+ const VOLTA_SHIM_NAMES = new Set(['volta-shim', 'volta-shim.exe']);
54
+
55
+ // 判断 PATH 上第一个可执行的 tmuxclihook 来自哪套安装机制:
56
+ // 'volta' — $VOLTA_HOME/bin 下的 shim、或 volta packages 镜像
57
+ // 'npm' — npm 全局 / nvm / fnm 等 node 工具链目录
58
+ // 'unknown' — PATH 上找不到可执行入口
59
+ // 跟随「当前生效入口」是 update 的核心语义:只升级用户实际会执行的那一份。
60
+ export function detectInstallSource(env = process.env) {
61
+ try {
62
+ const entry = findOnPath(PACKAGE_NAME, env);
63
+ if (!entry) return 'unknown';
64
+
65
+ const voltaHome = env.VOLTA_HOME || (env.HOME ? join(env.HOME, '.volta') : null);
66
+ const realEntry = realOrResolved(entry);
67
+
68
+ if (voltaHome) {
69
+ // macOS 的 /var → /private/var 等:原始路径与 canonical 路径都要比对
70
+ const homes = [voltaHome];
71
+ const realHome = realOrResolved(voltaHome);
72
+ if (realHome && realHome !== voltaHome) homes.push(realHome);
73
+
74
+ for (const home of homes) {
75
+ const binDir = join(home, 'bin');
76
+ const pkgDir = join(home, 'tools', 'image', 'packages', PACKAGE_NAME);
77
+ for (const candidate of [entry, realEntry]) {
78
+ if (!candidate) continue;
79
+ if (isPathInside(candidate, binDir) || isPathInside(candidate, pkgDir)) return 'volta';
80
+ }
81
+ }
82
+ }
83
+
84
+ if (realEntry && VOLTA_SHIM_NAMES.has(basename(realEntry))) return 'volta';
85
+ return 'npm';
86
+ } catch {
87
+ return 'unknown';
88
+ }
89
+ }
90
+
91
+ // —— 非生效副本检测与可选收敛 ——————————————————————————————————————
92
+ //
93
+ // update 只升级生效入口,不主动动另一套安装。历史遗留的双份副本会让后续
94
+ // 手动交叉升级「升级到不生效的那份」,因此提供可选收敛:只删同时满足以下
95
+ // 条件的副本——不是生效入口、未被 hooks 引用、未被 ~/.tmux.conf 引用、
96
+ // 位于已知标准位置。TTY 下交互确认(默认 No);--prune 跳过确认;
97
+ // 非交互环境只警告,绝不静默删除。
98
+
99
+ const TOOL_IDS = ['claude', 'codex', 'opencode'];
100
+
101
+ function voltaPackagesRoot(env) {
102
+ const voltaHome = env.VOLTA_HOME || (env.HOME ? join(env.HOME, '.volta') : null);
103
+ return voltaHome ? join(voltaHome, 'tools', 'image', 'packages', PACKAGE_NAME) : null;
104
+ }
105
+
106
+ async function readNpmGlobalPrefix(run) {
107
+ try {
108
+ const r = await run('npm', ['prefix', '-g']);
109
+ return (r.stdout || '').trim() || null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ // 返回「非生效 + 位于标准位置」的副本。source 为 unknown 时不做任何
116
+ // 检测——找不到生效入口时删除任何一份都可能让用户彻底没有命令。
117
+ export async function detectStaleInstalls(source, env = process.env, run = execa) {
118
+ if (source === 'unknown') return [];
119
+
120
+ const stale = [];
121
+ if (source === 'npm') {
122
+ const voltaRoot = voltaPackagesRoot(env);
123
+ if (voltaRoot && existsSync(voltaRoot)) stale.push({ kind: 'volta', root: voltaRoot });
124
+ }
125
+ if (source === 'volta') {
126
+ const prefix = await readNpmGlobalPrefix(run);
127
+ const npmRoot = prefix ? join(prefix, 'lib', 'node_modules', PACKAGE_NAME) : null;
128
+ if (npmRoot && existsSync(npmRoot)) stale.push({ kind: 'npm', root: npmRoot });
129
+ }
130
+ return stale;
131
+ }
132
+
133
+ // hooks 是否引用该副本。走三个 wrapper 的 check --json(与 TUI 同源),
134
+ // 输出里出现目标路径即视为引用。任何异常都按「有引用」处理,宁可不动。
135
+ async function hooksReference(root, env, run) {
136
+ const scriptsDir = fileURLToPath(new URL('../scripts/', import.meta.url));
137
+ for (const tool of TOOL_IDS) {
138
+ const script = join(scriptsDir, `install-${tool}-hooks.sh`);
139
+ if (!existsSync(script)) continue;
140
+ try {
141
+ const r = await run('bash', [script, 'check', '--json'], { env });
142
+ if ((r.stdout || '').includes(root)) return tool;
143
+ } catch (err) {
144
+ if ((err.stdout || '').includes(root)) return tool;
145
+ return tool;
146
+ }
147
+ }
148
+ return null;
149
+ }
150
+
151
+ function tmuxConfReferences(root, env) {
152
+ if (!env.HOME) return true;
153
+ try {
154
+ return readFileSync(join(env.HOME, '.tmux.conf'), 'utf8').includes(root);
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ async function uninstallStale(item, run) {
161
+ try {
162
+ if (item.kind === 'volta') {
163
+ await run('volta', ['uninstall', PACKAGE_NAME], { stdio: 'inherit' });
164
+ } else {
165
+ await run('npm', ['uninstall', '-g', PACKAGE_NAME], { stdio: 'inherit' });
166
+ }
167
+ return true;
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+
173
+ // 交互确认的默认实现:读一行,默认 No。测试通过 options.confirm 注入。
174
+ async function defaultConfirm(question) {
175
+ const { createInterface } = await import('node:readline/promises');
176
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
177
+ try {
178
+ const answer = await rl.question(`${question} [y/N] `);
179
+ return /^y(es)?$/i.test(answer.trim());
180
+ } finally {
181
+ rl.close();
182
+ }
183
+ }
184
+
185
+ // 清理非生效副本。返回是否实际删除过任何副本。
186
+ export async function cleanupStale(options) {
187
+ const {
188
+ source, env, run, log, prune, isTTY, confirm, checkHooks, checkTmuxConf,
189
+ } = options;
190
+
191
+ const stale = await options.detectStale(source, env, run);
192
+ if (stale.length === 0) return false;
193
+
194
+ let removed = false;
195
+ for (const item of stale) {
196
+ const label = `${item.kind} install at ${item.root}`;
197
+
198
+ const hookTool = await checkHooks(item.root, env, run);
199
+ if (hookTool) {
200
+ log.error(`Not removing ${label}: still referenced by ${hookTool} hooks. Reinstall hooks from the effective entry first.`);
201
+ continue;
202
+ }
203
+ if (checkTmuxConf(item.root, env)) {
204
+ log.error(`Not removing ${label}: still referenced by ~/.tmux.conf.`);
205
+ continue;
206
+ }
207
+
208
+ if (!prune) {
209
+ if (!isTTY) {
210
+ log.error(`Another install found: ${label}. Run "tmuxclihook update --prune" in a terminal to remove it.`);
211
+ continue;
212
+ }
213
+ const yes = await confirm(`Remove the non-effective ${label}?`);
214
+ if (!yes) {
215
+ log.log(`Keeping ${label}.`);
216
+ continue;
217
+ }
218
+ }
219
+
220
+ if (await uninstallStale(item, run)) {
221
+ log.log(`Removed ${label}.`);
222
+ removed = true;
223
+ } else {
224
+ log.error(`Failed to remove ${label}.`);
225
+ }
226
+ }
227
+ return removed;
228
+ }
229
+
34
230
  // npm 布局下入口是 .../lib/node_modules/<pkg>/bin/cli.js(或其 symlink):
35
231
  // realpath 后向上最多 3 层找 name 匹配的 package.json 读版本。版本管理器
36
232
  // shim 是独立二进制,向上找不到包 → 返回 null。
@@ -94,14 +290,31 @@ function warnNotEffective(log, result) {
94
290
  log.error(`If you use volta, run: volta install ${PACKAGE_NAME}@latest`);
95
291
  }
96
292
 
97
- // volta 生效修正:`npm install -g` 只写 node 工具链目录,不更新 volta 的
98
- // shim 镜像(实测 volta 2.0.2:shim npm 的全局 prefix 就是工具链目录),
99
- // 用户的命令经 ~/.volta/bin shim 解析时仍是旧版。因此只要检测到 volta
100
- // 就无条件补一次 volta install 完成生效(幂等,已是最新时秒回)。不走
101
- // 「自检失败才修」的条件路径:自检按 update 进程自己的 PATH 找第一份,
102
- // 可能恰好命中 npm -g 刚写的那份而误判 ok(实测 0.3.3 → 0.3.4:verify
103
- // 判 ok,修复从未触发,用户命令仍是旧版)。npm install -g 是安装语义,
104
- // volta install 是生效语义,两者都执行;用户无需改变安装习惯。
293
+ // 清理非生效副本,并在实际删除过副本后复检生效入口。升级成功路径与
294
+ // 「已是最新 + --prune」路径共用,避免两处逻辑漂移。清理失败只记录日志,
295
+ // 不影响 update 的返回值。
296
+ async function reconcileStaleCopies({
297
+ source, env, run, log, prune, isTTY, confirm,
298
+ checkHooks, checkTmuxConf, detectStale, verify, expectedVersion,
299
+ }) {
300
+ try {
301
+ const removed = await cleanupStale({
302
+ source, env, run, log, prune, isTTY, confirm, checkHooks, checkTmuxConf, detectStale,
303
+ });
304
+ if (removed && verify) {
305
+ const after = await verify(expectedVersion, env);
306
+ if (after.status !== 'ok') warnNotEffective(log, after);
307
+ }
308
+ } catch (err) {
309
+ log.error(`Cleanup skipped: ${err}`);
310
+ }
311
+ }
312
+
313
+ // volta 安装路径:当 PATH 上的生效入口来自 volta(shim 或 packages 镜像)
314
+ // 时,升级只能走 `volta install`——`npm install -g` 只写 node 工具链目录,
315
+ // 不会更新 volta 的 packages 镜像。此函数现在是 volta 分支的唯一升级动作,
316
+ // 不再作为 npm 安装后的无条件补丁(双写会制造两份副本并引发漂移)。
317
+ // volta 不可执行时返回 false,由调用方决定如何报告。
105
318
  export async function repairWithVolta(run, log, env = process.env) {
106
319
  const voltaHome = env.VOLTA_HOME || join(env.HOME || '', '.volta');
107
320
  const voltaBin = findOnPath('volta', env) || join(voltaHome, 'bin', 'volta');
@@ -124,9 +337,21 @@ export async function updatePackage(options = {}) {
124
337
  const run = options.run || execa;
125
338
  const log = options.log || console;
126
339
  const currentVersion = options.currentVersion || CURRENT_VERSION;
340
+ const env = options.env || process.env;
127
341
  // 显式传 null 跳过自检/修正(测试用);默认真实现
128
342
  const verify = options.verify === undefined ? verifyEffectiveVersion : options.verify;
129
343
  const repairVolta = options.repairVolta === undefined ? repairWithVolta : options.repairVolta;
344
+ const detectSource = options.detectSource === undefined ? detectInstallSource : options.detectSource;
345
+ // 非生效副本收敛:默认开启,TTY 下交互确认;--prune 跳过确认。
346
+ const cleanup = options.cleanup !== false;
347
+ const prune = options.prune === true;
348
+ const isTTY = options.isTTY === undefined
349
+ ? Boolean(process.stdin.isTTY && process.stdout.isTTY)
350
+ : options.isTTY;
351
+ const confirm = options.confirm || defaultConfirm;
352
+ const detectStale = options.detectStale || detectStaleInstalls;
353
+ const checkHooks = options.checkHooks || hooksReference;
354
+ const checkTmuxConf = options.checkTmuxConf || tmuxConfReferences;
130
355
 
131
356
  log.log(`Current version: v${currentVersion}`);
132
357
 
@@ -141,33 +366,73 @@ export async function updatePackage(options = {}) {
141
366
 
142
367
  if (currentVersion === latestVersion) {
143
368
  log.log('Already up to date.');
369
+
370
+ // 版本已最新:绝不安装;仅显式 --prune 时收敛非生效副本。
371
+ // 未传 --prune 时保持历史行为:不探测来源、不清理、直接返回。
372
+ if (cleanup && prune) {
373
+ const effectiveSource = typeof detectSource === 'function'
374
+ ? detectSource(env)
375
+ : 'unknown';
376
+ await reconcileStaleCopies({
377
+ source: effectiveSource, env, run, log, prune, isTTY, confirm,
378
+ checkHooks, checkTmuxConf, detectStale, verify,
379
+ expectedVersion: latestVersion,
380
+ });
381
+ }
382
+
144
383
  return true;
145
384
  }
146
385
 
147
386
  log.log(`Updating to v${latestVersion}...`);
148
- try {
149
- await run('npm', ['install', '-g', `${PACKAGE_NAME}@latest`], { stdio: 'inherit' });
150
- } catch {
151
- return false;
387
+
388
+ // 跟随当前生效入口:PATH 上第一个 tmuxclihook 属于哪套机制,就只升级那套。
389
+ // volta 入口走 volta install(npm -g 写不到 packages 镜像);其余入口走 npm -g。
390
+ // 不再双写——那会留下两份同版本副本,配置指向一份而命令执行另一份即产生漂移。
391
+ const source = typeof detectSource === 'function' ? detectSource(env) : 'unknown';
392
+
393
+ if (source === 'volta') {
394
+ if (repairVolta) {
395
+ const ok = await repairVolta(run, log, env);
396
+ if (!ok) {
397
+ log.error('Failed to update via volta. Please check your volta installation.');
398
+ return false;
399
+ }
400
+ }
401
+ } else {
402
+ try {
403
+ await run('npm', ['install', '-g', `${PACKAGE_NAME}@latest`], { stdio: 'inherit' });
404
+ } catch {
405
+ return false;
406
+ }
152
407
  }
153
408
 
154
409
  log.log(`Done. Updated to v${latestVersion}`);
155
410
 
156
- // 生效收敛:有 volta 就无条件跑(幂等;无 volta 时静默跳过,纯 npm 场景
157
- // npm -g 本身已生效)。任何命令行执行 update 都能升级生效的关键。
158
- if (repairVolta) {
159
- await repairVolta(run, log);
411
+ if (!verify) return true;
412
+
413
+ // 事后确认(尽力而为):正常情况下生效入口应已是新版。
414
+ let result = await verify(latestVersion, env);
415
+
416
+ // 兜底:非 volta 来源、自检未过、且本机确有 volta 时补一次并复检。
417
+ // 仅在异常场景触发(例如 npm 全局 bin 不在 PATH),保持「不双写」语义。
418
+ if (result.status !== 'ok' && source !== 'volta' && repairVolta) {
419
+ const repaired = await repairVolta(run, log, env);
420
+ if (repaired) result = await verify(latestVersion, env);
160
421
  }
161
422
 
162
- if (verify) {
163
- // 事后确认(尽力而为):两处落点都应已是新版,命中哪份都该 ok;
164
- // 不 ok(如 PATH 上另有第三份旧安装)只警告,不影响返回值。
165
- const result = await verify(latestVersion);
166
- if (result.status === 'ok') {
167
- log.log(`Verified: tmuxclihook on your PATH now resolves to v${latestVersion}.`);
168
- } else {
169
- warnNotEffective(log, result);
170
- }
423
+ if (result.status === 'ok') {
424
+ log.log(`Verified: tmuxclihook on your PATH now resolves to v${latestVersion}.`);
425
+ } else {
426
+ warnNotEffective(log, result);
427
+ }
428
+
429
+ // 升级已确认成功后再收敛历史副本;清理失败不影响升级结果,也不改变退出码。
430
+ if (cleanup && result.status === 'ok') {
431
+ await reconcileStaleCopies({
432
+ source, env, run, log, prune, isTTY, confirm,
433
+ checkHooks, checkTmuxConf, detectStale, verify,
434
+ expectedVersion: latestVersion,
435
+ });
171
436
  }
172
437
 
173
438
  return true;