@co0ontty/wand 2.9.1 → 2.10.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.
@@ -12,8 +12,8 @@
12
12
  * `@co0ontty/.wand-*` 残留目录;失败时恢复备份,避免运行中的服务被半成品安装拆掉。
13
13
  */
14
14
  import { execFile, spawnSync } from "node:child_process";
15
- import { chmodSync, cpSync, existsSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
16
- import os from "node:os";
15
+ import { randomUUID } from "node:crypto";
16
+ import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, readdirSync, renameSync, rmSync, statfsSync, statSync, symlinkSync, writeFileSync, } from "node:fs";
17
17
  import path from "node:path";
18
18
  import process from "node:process";
19
19
  import { promisify } from "node:util";
@@ -24,10 +24,14 @@ const execFileAsync = promisify(execFile);
24
24
  export const PACKAGE_NAME = "@co0ontty/wand";
25
25
  const PACKAGE_SCOPE = "@co0ontty";
26
26
  const PACKAGE_BASENAME = "wand";
27
- const NPM_BIN = process.platform === "win32" ? "npm.cmd" : "npm";
27
+ const DEFAULT_NPM_BIN = process.platform === "win32" ? "npm.cmd" : "npm";
28
28
  const COMMON_UNIX_PATHS = ["/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin"];
29
29
  const INSTALL_MAX_BUFFER = 10 * 1024 * 1024;
30
30
  const NPM_VIEW_TIMEOUT_MS = 15_000;
31
+ const UPDATE_DISK_RESERVE_BYTES = 512 * 1024 * 1024;
32
+ function npmBin() {
33
+ return process.env.WAND_NPM_BIN || DEFAULT_NPM_BIN;
34
+ }
31
35
  export function normalizeUpdateChannel(value) {
32
36
  return value === "beta" ? "beta" : "stable";
33
37
  }
@@ -83,7 +87,7 @@ export function buildPackageUpdateInfo(currentVersion, channel, latestVersion) {
83
87
  }
84
88
  async function viewPackageVersionAsync(channel, timeoutMs = NPM_VIEW_TIMEOUT_MS) {
85
89
  try {
86
- const { stdout } = await execFileAsync(NPM_BIN, ["view", getInstallSpecForChannel(channel), "version"], { timeout: timeoutMs, env: getChildEnv(), maxBuffer: INSTALL_MAX_BUFFER });
90
+ const { stdout } = await execFileAsync(npmBin(), ["view", getInstallSpecForChannel(channel), "version"], { timeout: timeoutMs, env: getChildEnv(), maxBuffer: INSTALL_MAX_BUFFER });
87
91
  const version = String(stdout || "").trim();
88
92
  return version || null;
89
93
  }
@@ -132,7 +136,7 @@ function runNpmSync(args, timeoutMs) {
132
136
  env: getChildEnv(),
133
137
  maxBuffer: INSTALL_MAX_BUFFER,
134
138
  };
135
- return spawnSync(NPM_BIN, args, options);
139
+ return spawnSync(npmBin(), args, options);
136
140
  }
137
141
  async function runNpmAsync(args, timeoutMs) {
138
142
  const options = {
@@ -140,7 +144,7 @@ async function runNpmAsync(args, timeoutMs) {
140
144
  env: getChildEnv(),
141
145
  maxBuffer: INSTALL_MAX_BUFFER,
142
146
  };
143
- await execFileAsync(NPM_BIN, args, options);
147
+ await execFileAsync(npmBin(), args, options);
144
148
  }
145
149
  /**
146
150
  * 解析当前 `npm root -g` 的目录。失败返回 null。
@@ -157,6 +161,40 @@ export function getNpmGlobalRoot() {
157
161
  return null;
158
162
  }
159
163
  }
164
+ function getNpmGlobalPrefix() {
165
+ try {
166
+ const res = runNpmSync(["prefix", "-g"], 10_000);
167
+ if (res.status !== 0)
168
+ return null;
169
+ const out = (res.stdout || "").trim();
170
+ return out.length > 0 ? out : null;
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ }
176
+ function getGlobalWandBinPaths() {
177
+ const prefix = getNpmGlobalPrefix();
178
+ if (!prefix)
179
+ return [];
180
+ if (process.platform === "win32") {
181
+ return [
182
+ path.join(prefix, PACKAGE_BASENAME),
183
+ path.join(prefix, `${PACKAGE_BASENAME}.cmd`),
184
+ path.join(prefix, `${PACKAGE_BASENAME}.ps1`),
185
+ ];
186
+ }
187
+ return [path.join(prefix, "bin", PACKAGE_BASENAME)];
188
+ }
189
+ function pathEntryExists(targetPath) {
190
+ try {
191
+ lstatSync(targetPath);
192
+ return true;
193
+ }
194
+ catch {
195
+ return false;
196
+ }
197
+ }
160
198
  /**
161
199
  * 清理上一次 npm install 失败留下的 `.wand-XXXXXX` 残留目录。
162
200
  *
@@ -169,34 +207,69 @@ export function cleanupNpmLeftovers() {
169
207
  const root = getNpmGlobalRoot();
170
208
  if (!root)
171
209
  return { removed, errors };
172
- const scopeDir = path.join(root, PACKAGE_SCOPE);
173
- if (!existsSync(scopeDir))
174
- return { removed, errors };
175
- let entries;
176
- try {
177
- entries = readdirSync(scopeDir);
178
- }
179
- catch (err) {
180
- errors.push(`readdir ${scopeDir}: ${getErrorMessage(err)}`);
181
- return { removed, errors };
182
- }
183
- // 残留目录形如 `.wand-PdFXStca`:以点开头 + 包基名 + 短横线 + 随机后缀
184
- const leftoverPattern = new RegExp(`^\\.${PACKAGE_BASENAME}-[A-Za-z0-9]+$`);
185
- for (const name of entries) {
186
- if (!leftoverPattern.test(name))
187
- continue;
188
- const fullPath = path.join(scopeDir, name);
210
+ // npm rename 临时项固定为 `.wand-` + 8 位随机字串。不要用宽泛前缀,
211
+ // 否则会误删用户/运维留下的 `.wand-backup` 等人工备份。
212
+ const leftoverPattern = new RegExp(`^\\.${PACKAGE_BASENAME}-[A-Za-z0-9]{8}$`);
213
+ const isWandPackageLeftover = (fullPath) => {
189
214
  try {
190
- // 仅清理目录,避免误删同名文件
191
- if (!statSync(fullPath).isDirectory())
192
- continue;
193
- rmSync(fullPath, { recursive: true, force: true });
194
- removed.push(fullPath);
215
+ if (!lstatSync(fullPath).isDirectory())
216
+ return false;
217
+ const manifest = JSON.parse(readFileSync(path.join(fullPath, "package.json"), "utf8"));
218
+ return manifest.name === PACKAGE_NAME;
219
+ }
220
+ catch {
221
+ return false;
222
+ }
223
+ };
224
+ const isWandBinLeftover = (fullPath) => {
225
+ try {
226
+ const entry = lstatSync(fullPath);
227
+ if (entry.isDirectory())
228
+ return false;
229
+ const marker = entry.isSymbolicLink()
230
+ ? readlinkSync(fullPath)
231
+ : readFileSync(fullPath, "utf8").slice(0, 16 * 1024);
232
+ return marker.includes(`${PACKAGE_SCOPE}/${PACKAGE_BASENAME}`)
233
+ || marker.includes(`${PACKAGE_SCOPE}${path.sep}${PACKAGE_BASENAME}`);
234
+ }
235
+ catch {
236
+ return false;
237
+ }
238
+ };
239
+ const cleanupDir = (dir, directoriesOnly) => {
240
+ if (!existsSync(dir))
241
+ return;
242
+ let entries;
243
+ try {
244
+ entries = readdirSync(dir);
195
245
  }
196
246
  catch (err) {
197
- errors.push(`rm ${fullPath}: ${getErrorMessage(err)}`);
247
+ errors.push(`readdir ${dir}: ${getErrorMessage(err)}`);
248
+ return;
198
249
  }
199
- }
250
+ for (const name of entries) {
251
+ if (!leftoverPattern.test(name))
252
+ continue;
253
+ const fullPath = path.join(dir, name);
254
+ try {
255
+ const belongsToWand = directoriesOnly
256
+ ? isWandPackageLeftover(fullPath)
257
+ : isWandBinLeftover(fullPath);
258
+ if (!belongsToWand)
259
+ continue;
260
+ // bin 目录绝不递归删除目录;scope 里只删除已验证 manifest 的 wand 包。
261
+ rmSync(fullPath, { recursive: directoriesOnly, force: true });
262
+ removed.push(fullPath);
263
+ }
264
+ catch (err) {
265
+ errors.push(`rm ${fullPath}: ${getErrorMessage(err)}`);
266
+ }
267
+ }
268
+ };
269
+ cleanupDir(path.join(root, PACKAGE_SCOPE), true);
270
+ const binPaths = getGlobalWandBinPaths();
271
+ if (binPaths.length > 0)
272
+ cleanupDir(path.dirname(binPaths[0]), false);
200
273
  return { removed, errors };
201
274
  }
202
275
  const REQUIRED_RUNTIME_FILES = [
@@ -216,11 +289,59 @@ function getGlobalPackageDir() {
216
289
  const root = getNpmGlobalRoot();
217
290
  return root ? path.join(root, PACKAGE_SCOPE, PACKAGE_BASENAME) : null;
218
291
  }
219
- function validateGlobalWandInstall() {
220
- const packageDir = getGlobalPackageDir();
221
- if (!packageDir) {
222
- return { ok: false, message: "无法解析 npm 全局安装目录。" };
292
+ function directorySizeSync(targetPath) {
293
+ const entry = lstatSync(targetPath);
294
+ if (entry.isSymbolicLink())
295
+ return 0;
296
+ if (!entry.isDirectory())
297
+ return entry.size;
298
+ let total = 0;
299
+ for (const name of readdirSync(targetPath)) {
300
+ total += directorySizeSync(path.join(targetPath, name));
223
301
  }
302
+ return total;
303
+ }
304
+ /**
305
+ * 更新时同时存在旧包备份、npm 正在解包的新版本和下载/回滚余量。
306
+ * 旧包大小按三倍计(安全备份、新包、回滚 staging),再保留 512 MiB,
307
+ * 避免 ENOSPC 把全局 CLI 拆成半包。
308
+ */
309
+ export function requiredUpdateFreeBytes(currentInstallBytes) {
310
+ const normalized = Number.isFinite(currentInstallBytes)
311
+ ? Math.max(0, Math.floor(currentInstallBytes))
312
+ : 0;
313
+ return normalized * 3 + UPDATE_DISK_RESERVE_BYTES;
314
+ }
315
+ function formatBytes(bytes) {
316
+ const gib = bytes / (1024 ** 3);
317
+ if (gib >= 1)
318
+ return `${gib.toFixed(2)} GiB`;
319
+ return `${(bytes / (1024 ** 2)).toFixed(0)} MiB`;
320
+ }
321
+ function nearestExistingAncestor(targetPath) {
322
+ let current = path.resolve(targetPath);
323
+ while (!existsSync(current)) {
324
+ const parent = path.dirname(current);
325
+ if (parent === current)
326
+ return current;
327
+ current = parent;
328
+ }
329
+ return current;
330
+ }
331
+ function assertUpdateDiskSpace(packageDir, note) {
332
+ const currentInstallBytes = existsSync(packageDir) ? directorySizeSync(packageDir) : 0;
333
+ const probePath = nearestExistingAncestor(packageDir);
334
+ const stats = statfsSync(probePath, { bigint: true });
335
+ const availableBytes = Number(stats.bavail * stats.bsize);
336
+ const requiredBytes = requiredUpdateFreeBytes(currentInstallBytes);
337
+ if (availableBytes < requiredBytes) {
338
+ throw new Error(`磁盘空间不足,已取消更新以保护当前安装:可用 ${formatBytes(availableBytes)},` +
339
+ `至少需要 ${formatBytes(requiredBytes)}(当前 wand ${formatBytes(currentInstallBytes)})。`);
340
+ }
341
+ note?.(`[wand] 更新磁盘预检通过: 可用 ${formatBytes(availableBytes)},` +
342
+ `需要 ${formatBytes(requiredBytes)}`);
343
+ }
344
+ function validateWandPackageDir(packageDir) {
224
345
  const missing = [];
225
346
  for (const rel of REQUIRED_RUNTIME_FILES) {
226
347
  const fullPath = path.join(packageDir, rel);
@@ -254,6 +375,41 @@ function validateGlobalWandInstall() {
254
375
  };
255
376
  }
256
377
  }
378
+ return { ok: true };
379
+ }
380
+ function validateGlobalWandInstall() {
381
+ const packageDir = getGlobalPackageDir();
382
+ if (!packageDir) {
383
+ return { ok: false, message: "无法解析 npm 全局安装目录。" };
384
+ }
385
+ const packageValidation = validateWandPackageDir(packageDir);
386
+ if (!packageValidation.ok)
387
+ return packageValidation;
388
+ const binPaths = getGlobalWandBinPaths();
389
+ const cliPath = path.join(packageDir, "dist", "cli.js");
390
+ const hasWorkingBin = process.platform === "win32"
391
+ ? binPaths.length > 0 && binPaths.every((binPath) => {
392
+ try {
393
+ return statSync(binPath).isFile();
394
+ }
395
+ catch {
396
+ return false;
397
+ }
398
+ })
399
+ : binPaths.length === 1 && (() => {
400
+ try {
401
+ return realpathSync(binPaths[0]) === realpathSync(cliPath);
402
+ }
403
+ catch {
404
+ return false;
405
+ }
406
+ })();
407
+ if (!hasWorkingBin) {
408
+ return {
409
+ ok: false,
410
+ message: `全局 wand 命令入口缺失: ${binPaths.join(", ") || "无法解析 npm prefix"}`,
411
+ };
412
+ }
257
413
  return { ok: true, packageDir };
258
414
  }
259
415
  function assertGlobalWandInstallComplete() {
@@ -265,48 +421,172 @@ function assertGlobalWandInstallComplete() {
265
421
  function createGlobalInstallBackup(note) {
266
422
  const packageDir = getGlobalPackageDir();
267
423
  if (!packageDir) {
268
- return { packageDir: "", backupDir: null };
424
+ throw new Error("无法解析 npm 全局安装目录,已取消更新以保护当前安装。");
269
425
  }
270
426
  if (!existsSync(packageDir)) {
271
- return { packageDir, backupDir: null };
427
+ assertUpdateDiskSpace(packageDir, note);
428
+ return { packageDir, backupRoot: null, backupDir: null, binEntries: [] };
429
+ }
430
+ // 备份前只要求包体完整;bin shim 本身可能正是待修复对象。
431
+ const validation = validateWandPackageDir(packageDir);
432
+ if (!validation.ok) {
433
+ throw new Error(`${validation.message};已取消更新,请先修复当前安装。`);
272
434
  }
273
- const backupRoot = mkdtempSync(path.join(os.tmpdir(), "wand-global-backup-"));
435
+ assertUpdateDiskSpace(packageDir, note);
436
+ const scopeDir = path.dirname(packageDir);
437
+ mkdirSync(scopeDir, { recursive: true });
438
+ // 与全局包同盘并避开 npm 的 `.wand-XXXXXXXX` 命名,崩溃/重启后仍可作为救援运行时。
439
+ const backupRoot = mkdtempSync(path.join(scopeDir, ".wand-safe-backup-"));
274
440
  const backupDir = path.join(backupRoot, PACKAGE_BASENAME);
441
+ const binEntries = [];
275
442
  try {
276
443
  cpSync(packageDir, backupDir, {
277
444
  recursive: true,
278
445
  dereference: false,
279
446
  verbatimSymlinks: true,
280
447
  });
448
+ const binBackupDir = path.join(backupRoot, "bin");
449
+ for (const [index, originalPath] of getGlobalWandBinPaths().entries()) {
450
+ if (!pathEntryExists(originalPath))
451
+ continue;
452
+ mkdirSync(binBackupDir, { recursive: true });
453
+ const backupPath = path.join(binBackupDir, `${index}-${path.basename(originalPath)}`);
454
+ cpSync(originalPath, backupPath, {
455
+ dereference: false,
456
+ verbatimSymlinks: true,
457
+ });
458
+ binEntries.push({ originalPath, backupPath });
459
+ }
281
460
  note?.(`[wand] 已备份当前全局安装: ${backupDir}`);
282
- return { packageDir, backupDir };
461
+ return { packageDir, backupRoot, backupDir, binEntries };
283
462
  }
284
463
  catch (err) {
285
464
  rmSync(backupRoot, { recursive: true, force: true });
286
- note?.(`[wand] 全局安装备份失败,继续尝试更新: ${getErrorMessage(err)}`);
287
- return { packageDir, backupDir: null };
465
+ throw new Error(`全局安装备份失败,已取消更新: ${getErrorMessage(err)}`);
288
466
  }
289
467
  }
290
468
  function cleanupGlobalInstallBackup(backup) {
291
- if (!backup.backupDir)
469
+ if (!backup.backupRoot)
292
470
  return;
293
- rmSync(path.dirname(backup.backupDir), { recursive: true, force: true });
471
+ rmSync(backup.backupRoot, { recursive: true, force: true });
472
+ }
473
+ function removeGlobalBinEntry(binPath) {
474
+ try {
475
+ if (lstatSync(binPath).isDirectory()) {
476
+ throw new Error(`拒绝递归删除异常的 wand bin 目录: ${binPath}`);
477
+ }
478
+ rmSync(binPath, { force: true });
479
+ }
480
+ catch (err) {
481
+ if (err.code !== "ENOENT")
482
+ throw err;
483
+ }
484
+ }
485
+ function pointPosixBinAtRecoveryBackup(backup) {
486
+ if (process.platform === "win32" || !backup.backupDir)
487
+ return;
488
+ const binPaths = getGlobalWandBinPaths();
489
+ if (binPaths.length !== 1)
490
+ throw new Error("无法解析 npm 全局 bin 目录。");
491
+ const binPath = binPaths[0];
492
+ const recoveryCli = path.join(backup.backupDir, "dist", "cli.js");
493
+ const recoveryValidation = validateWandPackageDir(backup.backupDir);
494
+ if (!recoveryValidation.ok)
495
+ throw new Error(recoveryValidation.message);
496
+ const binDir = path.dirname(binPath);
497
+ mkdirSync(binDir, { recursive: true });
498
+ const pendingLink = path.join(binDir, `.wand-recovery-${randomUUID()}`);
499
+ try {
500
+ symlinkSync(path.relative(binDir, recoveryCli), pendingLink);
501
+ // rename 覆盖文件/符号链接是原子的;若目标异常地是目录则拒绝并保留原状。
502
+ if (pathEntryExists(binPath) && lstatSync(binPath).isDirectory()) {
503
+ throw new Error(`拒绝替换异常的 wand bin 目录: ${binPath}`);
504
+ }
505
+ renameSync(pendingLink, binPath);
506
+ }
507
+ catch (err) {
508
+ rmSync(pendingLink, { force: true });
509
+ throw err;
510
+ }
511
+ }
512
+ function restoreGlobalBinEntries(backup) {
513
+ const binPaths = getGlobalWandBinPaths();
514
+ if (process.platform !== "win32") {
515
+ if (binPaths.length !== 1)
516
+ throw new Error("无法解析 npm 全局 bin 目录。");
517
+ const binPath = binPaths[0];
518
+ const cliPath = path.join(backup.packageDir, "dist", "cli.js");
519
+ mkdirSync(path.dirname(binPath), { recursive: true });
520
+ removeGlobalBinEntry(binPath);
521
+ symlinkSync(path.relative(path.dirname(binPath), cliPath), binPath);
522
+ return;
523
+ }
524
+ for (const entry of backup.binEntries) {
525
+ removeGlobalBinEntry(entry.originalPath);
526
+ mkdirSync(path.dirname(entry.originalPath), { recursive: true });
527
+ cpSync(entry.backupPath, entry.originalPath, {
528
+ dereference: false,
529
+ verbatimSymlinks: true,
530
+ });
531
+ }
294
532
  }
295
533
  function restoreGlobalInstallBackup(backup, note) {
296
534
  if (!backup.packageDir || !backup.backupDir || !existsSync(backup.backupDir))
297
535
  return false;
536
+ const scopeDir = path.dirname(backup.packageDir);
537
+ let stageRoot = null;
538
+ let quarantinePath = null;
298
539
  try {
299
- rmSync(backup.packageDir, { recursive: true, force: true });
300
- cpSync(backup.backupDir, backup.packageDir, {
540
+ // 回滚复制/目录切换期间,即使进程或机器硬退出,launchd/systemd 仍能从同盘完整备份启动。
541
+ pointPosixBinAtRecoveryBackup(backup);
542
+ // 先在 npm root 同盘 staging 复制并校验。复制失败时不碰当前目录,也不消费备份;
543
+ // staging 完整后才用同盘 rename 原子替换,避免回滚自身再次留下半包。
544
+ mkdirSync(scopeDir, { recursive: true });
545
+ stageRoot = mkdtempSync(path.join(scopeDir, ".wand-restore-"));
546
+ const stagedPackage = path.join(stageRoot, PACKAGE_BASENAME);
547
+ cpSync(backup.backupDir, stagedPackage, {
301
548
  recursive: true,
302
549
  dereference: false,
303
550
  verbatimSymlinks: true,
304
551
  });
552
+ const stagedValidation = validateWandPackageDir(stagedPackage);
553
+ if (!stagedValidation.ok)
554
+ throw new Error(stagedValidation.message);
555
+ // 不先 rm 当前目录:把它原子挪到同盘 quarantine,再 promote staging。
556
+ // promote 若失败,立即把原目录原子放回,避免失败路径留下空安装。
557
+ if (existsSync(backup.packageDir)) {
558
+ quarantinePath = path.join(scopeDir, `.wand-quarantine-${randomUUID()}`);
559
+ renameSync(backup.packageDir, quarantinePath);
560
+ }
561
+ try {
562
+ renameSync(stagedPackage, backup.packageDir);
563
+ }
564
+ catch (promoteError) {
565
+ if (quarantinePath && !existsSync(backup.packageDir) && existsSync(quarantinePath)) {
566
+ renameSync(quarantinePath, backup.packageDir);
567
+ quarantinePath = null;
568
+ }
569
+ throw promoteError;
570
+ }
571
+ restoreGlobalBinEntries(backup);
572
+ const validation = validateGlobalWandInstall();
573
+ if (!validation.ok)
574
+ throw new Error(validation.message);
575
+ if (quarantinePath) {
576
+ rmSync(quarantinePath, { recursive: true, force: true });
577
+ quarantinePath = null;
578
+ }
579
+ rmSync(stageRoot, { recursive: true, force: true });
580
+ stageRoot = null;
305
581
  note?.(`[wand] 已恢复更新前的全局安装: ${backup.packageDir}`);
306
582
  return true;
307
583
  }
308
584
  catch (err) {
309
- note?.(`[wand] 恢复更新前安装失败: ${getErrorMessage(err)}`);
585
+ if (stageRoot)
586
+ rmSync(stageRoot, { recursive: true, force: true });
587
+ note?.(`[wand] 恢复更新前安装失败: ${getErrorMessage(err)};` +
588
+ `备份保留在 ${backup.backupRoot ?? backup.backupDir}` +
589
+ `${quarantinePath ? `;替换前目录保留在 ${quarantinePath}` : ""}`);
310
590
  return false;
311
591
  }
312
592
  }
@@ -314,7 +594,57 @@ async function npmInstallGlobalAsync(pkg, timeoutMs, extra = []) {
314
594
  await runNpmAsync(["install", "-g", ...extra, pkg], timeoutMs);
315
595
  }
316
596
  function isRecoverableInstallError(message) {
317
- return /ENOTEMPTY|EEXIST|全局 wand 安装不完整|无法解析 npm 全局安装目录|全局 wand CLI 无法设置执行权限/.test(message);
597
+ return /ENOTEMPTY|EEXIST|全局 wand 安装不完整|无法解析 npm 全局安装目录|全局 wand CLI 无法设置执行权限|全局 wand 命令入口缺失/.test(message);
598
+ }
599
+ function acquireGlobalUpdateLock() {
600
+ const root = getNpmGlobalRoot();
601
+ if (!root)
602
+ throw new Error("无法解析 npm 全局安装目录,不能建立更新锁。");
603
+ const scopeDir = path.join(root, PACKAGE_SCOPE);
604
+ // 放在 npm root,而不是 @scope 内;npm uninstall 可能清理空 scope 目录。
605
+ const lockPath = path.join(root, ".wand-update-lock");
606
+ const token = randomUUID();
607
+ mkdirSync(scopeDir, { recursive: true });
608
+ try {
609
+ mkdirSync(lockPath);
610
+ writeFileSync(path.join(lockPath, "owner.json"), `${JSON.stringify({ pid: process.pid, token, createdAt: Date.now() })}\n`, "utf8");
611
+ return { lockPath, token };
612
+ }
613
+ catch (err) {
614
+ if (err.code !== "EEXIST") {
615
+ // 只有本次成功 mkdir 后写 owner 失败时才清;owner token 防止误删别人的锁。
616
+ try {
617
+ const owner = JSON.parse(readFileSync(path.join(lockPath, "owner.json"), "utf8"));
618
+ if (owner.token === token)
619
+ rmSync(lockPath, { recursive: true, force: true });
620
+ }
621
+ catch {
622
+ /* 无法证明归属,不删除 */
623
+ }
624
+ throw err;
625
+ }
626
+ }
627
+ let ownerPid = 0;
628
+ try {
629
+ const owner = JSON.parse(readFileSync(path.join(lockPath, "owner.json"), "utf8"));
630
+ ownerPid = typeof owner.pid === "number" ? owner.pid : 0;
631
+ }
632
+ catch {
633
+ /* owner may still be writing; the directory itself is the atomic lock */
634
+ }
635
+ throw new Error(`另一个 wand 更新正在进行中(锁: ${lockPath}${ownerPid ? `, PID ${ownerPid}` : ""})。` +
636
+ "若确认没有更新进程,请手动删除该锁目录。");
637
+ }
638
+ function releaseGlobalUpdateLock(lock) {
639
+ try {
640
+ const owner = JSON.parse(readFileSync(path.join(lock.lockPath, "owner.json"), "utf8"));
641
+ if (owner.token === lock.token) {
642
+ rmSync(lock.lockPath, { recursive: true, force: true });
643
+ }
644
+ }
645
+ catch {
646
+ // 无法确认 owner 时宁可留下陈旧锁,也不能删掉另一个更新者的锁。
647
+ }
318
648
  }
319
649
  /**
320
650
  * 异步版本的全局安装:
@@ -332,9 +662,11 @@ export async function installPackageGloballyAsync(pkg, timeoutMs, log) {
332
662
  if (log)
333
663
  log(line);
334
664
  };
335
- const backup = createGlobalInstallBackup(note);
665
+ const updateLock = acquireGlobalUpdateLock();
666
+ let backup = null;
336
667
  let success = false;
337
668
  try {
669
+ backup = createGlobalInstallBackup(note);
338
670
  const cleanup = cleanupNpmLeftovers();
339
671
  if (cleanup.removed.length > 0) {
340
672
  note(`[wand] 清理 npm 残留目录: ${cleanup.removed.join(", ")}`);
@@ -350,7 +682,7 @@ export async function installPackageGloballyAsync(pkg, timeoutMs, log) {
350
682
  if (!isRecoverableInstallError(msg)) {
351
683
  throw error;
352
684
  }
353
- if (/全局 wand 安装不完整|无法解析 npm 全局安装目录|全局 wand CLI 无法设置执行权限/.test(msg)) {
685
+ if (/全局 wand 安装不完整|无法解析 npm 全局安装目录|全局 wand CLI 无法设置执行权限|全局 wand 命令入口缺失/.test(msg)) {
354
686
  note(`[wand] npm install 后安装目录不完整,尝试强制重装...`);
355
687
  }
356
688
  else {
@@ -386,12 +718,21 @@ export async function installPackageGloballyAsync(pkg, timeoutMs, log) {
386
718
  success = true;
387
719
  }
388
720
  finally {
389
- if (!success) {
390
- if (restoreGlobalInstallBackup(backup, note)) {
391
- cleanupNpmLeftovers();
721
+ try {
722
+ let restored = false;
723
+ if (!success && backup) {
724
+ restored = restoreGlobalInstallBackup(backup, note);
725
+ if (restored) {
726
+ cleanupNpmLeftovers();
727
+ }
392
728
  }
729
+ // 恢复失败时绝不能再删唯一完整备份,留给人工恢复。
730
+ if (backup && (success || restored))
731
+ cleanupGlobalInstallBackup(backup);
732
+ }
733
+ finally {
734
+ releaseGlobalUpdateLock(updateLock);
393
735
  }
394
- cleanupGlobalInstallBackup(backup);
395
736
  }
396
737
  }
397
738
  /**
@@ -402,7 +743,26 @@ export async function installPackageGloballyAsync(pkg, timeoutMs, log) {
402
743
  export function installPackageGloballySync(pkg, timeoutMs) {
403
744
  const attempts = [];
404
745
  const backupNotes = [];
405
- const backup = createGlobalInstallBackup((line) => backupNotes.push(line));
746
+ let updateLock;
747
+ try {
748
+ updateLock = acquireGlobalUpdateLock();
749
+ }
750
+ catch (err) {
751
+ return { status: 1, stdout: "", stderr: getErrorMessage(err), attempts };
752
+ }
753
+ let backup;
754
+ try {
755
+ backup = createGlobalInstallBackup((line) => backupNotes.push(line));
756
+ }
757
+ catch (err) {
758
+ releaseGlobalUpdateLock(updateLock);
759
+ return {
760
+ status: 1,
761
+ stdout: "",
762
+ stderr: getErrorMessage(err),
763
+ attempts,
764
+ };
765
+ }
406
766
  const withValidation = (res) => {
407
767
  if (res.status !== 0)
408
768
  return res;
@@ -431,42 +791,58 @@ export function installPackageGloballySync(pkg, timeoutMs) {
431
791
  attempts,
432
792
  });
433
793
  const finishSuccess = (res) => {
434
- cleanupGlobalInstallBackup(backup);
435
- return withBackupNotes(res);
794
+ try {
795
+ cleanupGlobalInstallBackup(backup);
796
+ return withBackupNotes(res);
797
+ }
798
+ finally {
799
+ releaseGlobalUpdateLock(updateLock);
800
+ }
436
801
  };
437
802
  const finishFailure = (res) => {
438
- if (restoreGlobalInstallBackup(backup, (line) => backupNotes.push(line))) {
439
- cleanupNpmLeftovers();
803
+ try {
804
+ const restored = restoreGlobalInstallBackup(backup, (line) => backupNotes.push(line));
805
+ if (restored) {
806
+ cleanupNpmLeftovers();
807
+ cleanupGlobalInstallBackup(backup);
808
+ }
809
+ return withBackupNotes(res);
810
+ }
811
+ finally {
812
+ releaseGlobalUpdateLock(updateLock);
440
813
  }
441
- cleanupGlobalInstallBackup(backup);
442
- return withBackupNotes(res);
443
814
  };
444
- cleanupNpmLeftovers();
445
- let res = tryInstall([]);
446
- if (res.status === 0) {
447
- return finishSuccess(res);
448
- }
449
- const hitRecoverableInstallError = (r) => isRecoverableInstallError(r.stdout + r.stderr);
450
- if (!hitRecoverableInstallError(res)) {
451
- return finishFailure(res);
452
- }
453
- cleanupNpmLeftovers();
454
- res = tryInstall([]);
455
- if (res.status === 0) {
456
- return finishSuccess(res);
457
- }
458
- if (!hitRecoverableInstallError(res)) {
815
+ try {
816
+ cleanupNpmLeftovers();
817
+ let res = tryInstall([]);
818
+ if (res.status === 0) {
819
+ return finishSuccess(res);
820
+ }
821
+ const hitRecoverableInstallError = (r) => isRecoverableInstallError(r.stdout + r.stderr);
822
+ if (!hitRecoverableInstallError(res)) {
823
+ return finishFailure(res);
824
+ }
825
+ cleanupNpmLeftovers();
826
+ res = tryInstall([]);
827
+ if (res.status === 0) {
828
+ return finishSuccess(res);
829
+ }
830
+ if (!hitRecoverableInstallError(res)) {
831
+ return finishFailure(res);
832
+ }
833
+ // 终极兜底(卸载用固定包名,兼容 git spec,见 async 版同样注释)
834
+ attempts.push(`npm uninstall -g ${PACKAGE_NAME}`);
835
+ runNpmSync(["uninstall", "-g", PACKAGE_NAME], timeoutMs);
836
+ cleanupNpmLeftovers();
837
+ res = tryInstall(["--force"]);
838
+ if (res.status === 0) {
839
+ return finishSuccess(res);
840
+ }
459
841
  return finishFailure(res);
460
842
  }
461
- // 终极兜底(卸载用固定包名,兼容 git spec,见 async 版同样注释)
462
- attempts.push(`npm uninstall -g ${PACKAGE_NAME}`);
463
- runNpmSync(["uninstall", "-g", PACKAGE_NAME], timeoutMs);
464
- cleanupNpmLeftovers();
465
- res = tryInstall(["--force"]);
466
- if (res.status === 0) {
467
- return finishSuccess(res);
843
+ catch (err) {
844
+ return finishFailure({ status: 1, stdout: "", stderr: getErrorMessage(err) });
468
845
  }
469
- return finishFailure(res);
470
846
  }
471
847
  /**
472
848
  * 解析「刚装好的全局 wand CLI 入口」(dist/cli.js) 的绝对路径。
@@ -494,3 +870,15 @@ export function resolveGlobalWandCli() {
494
870
  return found;
495
871
  return null;
496
872
  }
873
+ /**
874
+ * 返回 npm 全局命令 shim 的稳定路径。服务 unit 应固定到这个入口,而不是包目录内的
875
+ * dist/cli.js;更新回滚期间 shim 会临时指向同盘安全备份,始终保持可启动。
876
+ */
877
+ export function resolveGlobalWandBin() {
878
+ const candidates = getGlobalWandBinPaths();
879
+ for (const candidate of candidates) {
880
+ if (pathEntryExists(candidate))
881
+ return candidate;
882
+ }
883
+ return null;
884
+ }