@co0ontty/wand 1.72.0 → 1.74.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.
@@ -5,7 +5,7 @@
5
5
  * 命令本身不直接写 stdout / stderr —— TUI 模式下 stderr 已经被 log-bus 劫持。
6
6
  */
7
7
  import { spawn, spawnSync } from "node:child_process";
8
- import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
8
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from "node:fs";
9
9
  import os from "node:os";
10
10
  import path from "node:path";
11
11
  import process from "node:process";
@@ -513,12 +513,59 @@ function currentUserName() {
513
513
  return process.env.USER || process.env.LOGNAME || "root";
514
514
  }
515
515
  }
516
+ function ownerUserNameForPath(targetPath) {
517
+ const candidates = [targetPath, path.dirname(targetPath)];
518
+ for (const candidate of candidates) {
519
+ try {
520
+ const uid = statUid(candidate);
521
+ if (process.env.SUDO_UID === String(uid) && process.env.SUDO_USER) {
522
+ return process.env.SUDO_USER;
523
+ }
524
+ const resolved = spawnSync("id", ["-un", String(uid)], { encoding: "utf8", timeout: 3000 });
525
+ const name = (resolved.stdout || "").trim();
526
+ if (resolved.status === 0 && name)
527
+ return name;
528
+ }
529
+ catch {
530
+ /* try next candidate */
531
+ }
532
+ }
533
+ return process.env.SUDO_USER || currentUserName();
534
+ }
535
+ function homeForUser(userName, fallback) {
536
+ if (!userName || userName === "root")
537
+ return fallback;
538
+ if (process.platform === "darwin") {
539
+ const resolved = spawnSync("dscl", [".", "-read", `/Users/${userName}`, "NFSHomeDirectory"], {
540
+ encoding: "utf8",
541
+ timeout: 3000,
542
+ });
543
+ const match = (resolved.stdout || "").match(/NFSHomeDirectory:\s*(.+)/);
544
+ if (resolved.status === 0 && match?.[1])
545
+ return match[1].trim();
546
+ }
547
+ if (process.platform === "linux") {
548
+ const resolved = spawnSync("getent", ["passwd", userName], { encoding: "utf8", timeout: 3000 });
549
+ const home = (resolved.stdout || "").split(":")[5];
550
+ if (resolved.status === 0 && home)
551
+ return home.trim();
552
+ }
553
+ return fallback;
554
+ }
555
+ function statUid(targetPath) {
556
+ return readFileStat(targetPath).uid;
557
+ }
558
+ function readFileStat(targetPath) {
559
+ return statSync(targetPath);
560
+ }
516
561
  function installSystemdService(ctx, scope) {
517
562
  const unitPath = servicePathFor(scope);
518
563
  const wandBin = resolveWandBin(ctx);
519
564
  const nodeBin = process.execPath;
520
565
  const nodeBinDir = path.dirname(nodeBin);
521
- const runHome = process.env.HOME || os.homedir();
566
+ const runUser = scope === "system" ? ownerUserNameForPath(ctx.configPath) : currentUserName();
567
+ const fallbackHome = process.env.HOME || os.homedir();
568
+ const runHome = scope === "system" ? homeForUser(runUser, fallbackHome) : fallbackHome;
522
569
  // 关键:把调用 `wand service:install` 时的真实 PATH 写进 unit。
523
570
  // 否则 systemd 默认 PATH 极简(system scope 之前写死 `nodeBin:/usr/local/...`,
524
571
  // user scope 干脆没写),spawn 出的 claude/codex 子进程会撞 "command not found"
@@ -544,7 +591,6 @@ function installSystemdService(ctx, scope) {
544
591
  ];
545
592
  let unitLines;
546
593
  if (scope === "system") {
547
- const runUser = currentUserName();
548
594
  unitLines = [
549
595
  ...commonExec,
550
596
  `User=${runUser}`,
@@ -628,16 +674,20 @@ function installLaunchdService(ctx, scope) {
628
674
  const wandBin = resolveWandBin(ctx);
629
675
  const nodeBin = process.execPath;
630
676
  const nodeBinDir = path.dirname(nodeBin);
631
- const runHome = process.env.HOME || os.homedir();
677
+ const runUser = scope === "system" ? ownerUserNameForPath(ctx.configPath) : currentUserName();
678
+ const fallbackHome = process.env.HOME || os.homedir();
679
+ const runHome = scope === "system" ? homeForUser(runUser, fallbackHome) : fallbackHome;
632
680
  // 与 systemd 同理:launchd 默认 PATH 极简,spawn 出的 claude 会找不到。
633
681
  const servicePath = buildServicePath(nodeBinDir, runHome);
634
- // LaunchDaemon (system) 跑在 root,但 wand 数据应该归 ctx.configPath 的 owner;
635
- // 简化处理:system 模式下不强制改 owner,让用户自己提前 chown。
682
+ const userNameField = scope === "system"
683
+ ? ` <key>UserName</key><string>${runUser}</string>\n`
684
+ : "";
636
685
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
637
686
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
638
687
  <plist version="1.0">
639
688
  <dict>
640
689
  <key>Label</key><string>com.wand.web</string>
690
+ ${userNameField} <key>WorkingDirectory</key><string>${runHome}</string>
641
691
  <key>ProgramArguments</key>
642
692
  <array>
643
693
  <string>${nodeBin}</string>
package/dist/tui/index.js CHANGED
@@ -218,9 +218,9 @@ export function startTui(deps) {
218
218
  // 默认装 system-wide。非 root 时 installService 会返回明确错误,toast 自然展示。
219
219
  const isRoot = typeof process.getuid === "function" ? process.getuid() === 0 : false;
220
220
  const body = process.platform === "linux"
221
- ? `将写入 /etc/systemd/system/wand.service,systemctl enable --now,开机自启。\n${isRoot ? "当前是 root,可以直接装。" : "⚠ 需要 root,建议先 Ctrl+C 退出 TUI sudo wand web 重新进。"}\n不想要 root?退出 TUI 跑 wand service:install --user (登出会被回收)。`
221
+ ? `将写入 /etc/systemd/system/wand.service,systemctl enable --now,开机自启。\n${isRoot ? "当前是 root,可以直接装。" : "⚠ 需要 root,请退出 TUI 后直接跑 sudo wand service:install。"}\n不想要 root?退出 TUI 跑 wand service:install --user (登出会被回收)。`
222
222
  : process.platform === "darwin"
223
- ? `将写入 /Library/LaunchDaemons/com.wand.web.plist,launchctl load,开机自启。\n${isRoot ? "当前是 root,可以直接装。" : "⚠ 需要 root,建议先 Ctrl+C 退出 TUI sudo wand web 重新进。"}`
223
+ ? `将写入 /Library/LaunchDaemons/com.wand.web.plist,launchctl load,开机自启。\n${isRoot ? "当前是 root,可以直接装。" : "⚠ 需要 root,请退出 TUI 后直接跑 sudo wand service:install。"}`
224
224
  : "当前平台暂不支持。";
225
225
  const ok = await layout.confirm({ title: "注册为系统服务", body });
226
226
  if (!ok)
@@ -4,7 +4,8 @@
4
4
  * Windows 不支持(socketPath 返回空字符串),调用方应跳过 startIpcServer。
5
5
  */
6
6
  import net from "node:net";
7
- import { existsSync, unlinkSync } from "node:fs";
7
+ import { chmodSync, chownSync, existsSync, statSync, unlinkSync } from "node:fs";
8
+ import path from "node:path";
8
9
  import { getErrorMessage } from "../error-utils.js";
9
10
  export function startIpcServer(deps) {
10
11
  if (!deps.socketPath)
@@ -83,7 +84,9 @@ export function startIpcServer(deps) {
83
84
  // 不要崩进程;attach 模式不可用也不影响主功能。
84
85
  process.stderr.write(`[wand] IPC server error: ${err.message}\n`);
85
86
  });
86
- server.listen(deps.socketPath);
87
+ server.listen(deps.socketPath, () => {
88
+ applySocketOwnership(deps.socketPath);
89
+ });
87
90
  return {
88
91
  close: async () => {
89
92
  await new Promise((resolve) => {
@@ -99,3 +102,18 @@ export function startIpcServer(deps) {
99
102
  },
100
103
  };
101
104
  }
105
+ function applySocketOwnership(socketPath) {
106
+ try {
107
+ const owner = statSync(path.dirname(socketPath));
108
+ chownSync(socketPath, owner.uid, owner.gid);
109
+ }
110
+ catch {
111
+ /* best effort: non-root processes cannot chown, and usually already own the socket */
112
+ }
113
+ try {
114
+ chmodSync(socketPath, 0o600);
115
+ }
116
+ catch {
117
+ /* noop */
118
+ }
119
+ }