@co0ontty/wand 1.22.0 → 1.23.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.
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env -S node --disable-warning=ExperimentalWarning
2
2
  import process from "node:process";
3
3
  import { hasConfigFile, isPreferenceKey, loadConfigWithStorage, resolveConfigPath, saveConfig, writePreferenceToStorage, } from "./config.js";
4
+ import { readLiveInstance, removePidfile, removeSocketFile, socketPath, writePidfile, } from "./pidfile.js";
4
5
  async function main() {
5
6
  const args = process.argv.slice(2);
6
7
  const command = args[0] || "help";
@@ -13,12 +14,28 @@ async function main() {
13
14
  case "web": {
14
15
  const useTui = shouldUseTui();
15
16
  // web 命令下"已存在"的 ready 信息由统一的启动 banner / TUI 展示,避免重复。
16
- // "Created..." 这种首次创建事件仍会输出(ensureRequiredFiles 内部判断)。
17
17
  const config = await ensureRequiredFiles(configPath, { silentReady: true });
18
+ // —— 单实例检测:如果已有 wand 主进程在跑,直接 attach 而不重启服务 ——
19
+ const live = readLiveInstance(configPath);
20
+ if (live) {
21
+ await runAttach(live, configPath, useTui);
22
+ break;
23
+ }
18
24
  const { ensureNodePtyHelperExecutable } = await import("./ensure-node-pty-helper.js");
19
25
  ensureNodePtyHelperExecutable();
20
26
  const { startServer } = await import("./server.js");
21
27
  const handle = await startServer(config, configPath);
28
+ // —— 注册实例:写 pidfile + 启动 IPC 服务端(TUI / banner 模式都需要) ——
29
+ const startedAtMs = Date.now();
30
+ const ipcCtx = await registerInstance(handle, configPath, startedAtMs);
31
+ const cleanup = async () => {
32
+ try {
33
+ await ipcCtx.close();
34
+ }
35
+ catch { /* noop */ }
36
+ removePidfile(configPath);
37
+ removeSocketFile(configPath);
38
+ };
22
39
  if (useTui) {
23
40
  const { startTui } = await import("./tui/index.js");
24
41
  const tui = startTui({
@@ -32,6 +49,7 @@ async function main() {
32
49
  urls: handle.urls,
33
50
  orphanRecoveredCount: handle.orphanRecoveredCount,
34
51
  onExit: async () => {
52
+ await cleanup();
35
53
  await handle.close();
36
54
  process.exit(0);
37
55
  },
@@ -42,6 +60,16 @@ async function main() {
42
60
  }
43
61
  else {
44
62
  printStartupBanner(handle);
63
+ const onSignal = async () => {
64
+ await cleanup();
65
+ try {
66
+ await handle.close();
67
+ }
68
+ catch { /* noop */ }
69
+ process.exit(0);
70
+ };
71
+ process.on("SIGINT", () => { void onSignal(); });
72
+ process.on("SIGTERM", () => { void onSignal(); });
45
73
  }
46
74
  break;
47
75
  }
@@ -111,7 +139,7 @@ function printHelp() {
111
139
 
112
140
  Commands:
113
141
  wand init Create default files in ~/.wand/
114
- wand web Start web console server
142
+ wand web Start web console server (or attach to running one)
115
143
  wand config:path Print resolved config path
116
144
  wand config:show Print current config
117
145
  wand config:set Update a simple config value
@@ -186,6 +214,87 @@ function printStartupBanner(handle) {
186
214
  }
187
215
  process.stdout.write(lines.join("\n") + "\n");
188
216
  }
217
+ /** 写 pidfile + 启动 IPC 服务端。返回 cleanup 用的 close()。 */
218
+ async function registerInstance(handle, configPath, startedAtMs) {
219
+ const sockPath = socketPath(configPath);
220
+ const primary = handle.urls[0];
221
+ const pidInfo = {
222
+ pid: process.pid,
223
+ version: handle.version,
224
+ startedAt: startedAtMs,
225
+ url: primary ? primary.url : `${handle.httpsEnabled ? "https" : "http"}://${handle.bindAddr}`,
226
+ scheme: primary ? primary.scheme : (handle.httpsEnabled ? "HTTPS" : "HTTP"),
227
+ bindAddr: handle.bindAddr,
228
+ configPath: handle.configPath,
229
+ dbPath: handle.dbPath,
230
+ socket: sockPath,
231
+ };
232
+ // Windows 不开 IPC,仍然写 pidfile(attach 模式会跳过,但能给运维查 PID)
233
+ if (!sockPath) {
234
+ writePidfile(configPath, pidInfo);
235
+ return { close: async () => { } };
236
+ }
237
+ const { startIpcServer } = await import("./tui/ipc-server.js");
238
+ const { buildSnapshotData } = await import("./tui/snapshot.js");
239
+ const ipc = startIpcServer({
240
+ socketPath: sockPath,
241
+ snapshotProvider: () => buildSnapshotData({
242
+ version: handle.version,
243
+ url: pidInfo.url,
244
+ scheme: pidInfo.scheme,
245
+ bindAddr: handle.bindAddr,
246
+ configPath: handle.configPath,
247
+ dbPath: handle.dbPath,
248
+ orphanRecoveredCount: handle.orphanRecoveredCount,
249
+ startedAtMs,
250
+ pid: process.pid,
251
+ processManager: handle.processManager,
252
+ structuredSessions: handle.structuredSessions,
253
+ }),
254
+ onShutdown: async () => {
255
+ try {
256
+ await handle.close();
257
+ }
258
+ catch { /* noop */ }
259
+ removePidfile(configPath);
260
+ removeSocketFile(configPath);
261
+ process.exit(0);
262
+ },
263
+ });
264
+ writePidfile(configPath, pidInfo);
265
+ return {
266
+ close: async () => {
267
+ try {
268
+ await ipc?.close();
269
+ }
270
+ catch { /* noop */ }
271
+ },
272
+ };
273
+ }
274
+ /** 进入 attach 模式。 */
275
+ async function runAttach(live, configPath, useTui) {
276
+ if (!live.socket) {
277
+ // Windows 没 socket:直接打印信息后退出
278
+ process.stdout.write(`[wand] detected running instance pid=${live.pid} at ${live.url}\n` +
279
+ ` attach mode requires unix socket which is unavailable on this platform\n`);
280
+ process.exit(0);
281
+ }
282
+ if (!useTui) {
283
+ process.stdout.write(`[wand] running instance detected (pid=${live.pid}) at ${live.url}\n` +
284
+ ` socket=${live.socket}\n` +
285
+ ` use 'wand web' from a TTY to open the attach TUI\n`);
286
+ process.exit(0);
287
+ }
288
+ const { startAttachTui } = await import("./tui/attach.js");
289
+ const tui = startAttachTui({
290
+ pidInfo: live,
291
+ configPath,
292
+ onExit: async () => { process.exit(0); },
293
+ });
294
+ const onSignal = () => { void tui.stop(); };
295
+ process.on("SIGINT", onSignal);
296
+ process.on("SIGTERM", onSignal);
297
+ }
189
298
  function setConfigValue(config, key, value) {
190
299
  // 偏好字段(defaultMode/defaultCwd/...)由调用方分流到 storage,这里只处理 JSON 字段。
191
300
  switch (key) {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 单实例 pidfile + IPC 套接字路径。
3
+ *
4
+ * 用途:
5
+ * 1. `wand web` 启动时先看 pidfile 是否活着,活着则进入 attach 模式(不再重复启动服务)。
6
+ * 2. 主进程通过 wand.sock 暴露控制平面 IPC,attach 客户端通过它拉 snapshot / 发命令。
7
+ *
8
+ * 文件位置:均放在 configPath 的同目录(与 wand.db 一致)。
9
+ *
10
+ * 平台说明:
11
+ * - Linux / macOS 使用 Unix domain socket。
12
+ * - Windows 不支持,attach 模式直接跳过;新启的 `wand web` 仍会按老逻辑启动(端口冲突时报错)。
13
+ */
14
+ export interface PidInfo {
15
+ pid: number;
16
+ version: string;
17
+ /** 主进程启动时间 (ms epoch)。 */
18
+ startedAt: number;
19
+ url: string;
20
+ scheme: "HTTP" | "HTTPS";
21
+ bindAddr: string;
22
+ configPath: string;
23
+ dbPath: string;
24
+ /** Unix socket 绝对路径;Windows 下为空字符串。 */
25
+ socket: string;
26
+ }
27
+ export declare function pidfilePath(configPath: string): string;
28
+ export declare function socketPath(configPath: string): string;
29
+ /** 原子写:先写到 .tmp 再 rename。 */
30
+ export declare function writePidfile(configPath: string, info: PidInfo): void;
31
+ /** 读取并校验。文件不存在 / 损坏 / 进程不在 → 返回 null。 */
32
+ export declare function readPidfile(configPath: string): PidInfo | null;
33
+ /** 通过 `kill 0` 判断 PID 是否还活着(不发送信号,只检查存在性)。 */
34
+ export declare function isPidAlive(pid: number): boolean;
35
+ export declare function removePidfile(configPath: string): void;
36
+ export declare function removeSocketFile(configPath: string): void;
37
+ /** 读取 pidfile 并校验进程是否还活着。Stale 文件会自动清理。 */
38
+ export declare function readLiveInstance(configPath: string): PidInfo | null;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * 单实例 pidfile + IPC 套接字路径。
3
+ *
4
+ * 用途:
5
+ * 1. `wand web` 启动时先看 pidfile 是否活着,活着则进入 attach 模式(不再重复启动服务)。
6
+ * 2. 主进程通过 wand.sock 暴露控制平面 IPC,attach 客户端通过它拉 snapshot / 发命令。
7
+ *
8
+ * 文件位置:均放在 configPath 的同目录(与 wand.db 一致)。
9
+ *
10
+ * 平台说明:
11
+ * - Linux / macOS 使用 Unix domain socket。
12
+ * - Windows 不支持,attach 模式直接跳过;新启的 `wand web` 仍会按老逻辑启动(端口冲突时报错)。
13
+ */
14
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
15
+ import path from "node:path";
16
+ import process from "node:process";
17
+ export function pidfilePath(configPath) {
18
+ return path.resolve(path.dirname(configPath), "wand.pid");
19
+ }
20
+ export function socketPath(configPath) {
21
+ if (process.platform === "win32")
22
+ return "";
23
+ return path.resolve(path.dirname(configPath), "wand.sock");
24
+ }
25
+ /** 原子写:先写到 .tmp 再 rename。 */
26
+ export function writePidfile(configPath, info) {
27
+ const file = pidfilePath(configPath);
28
+ const tmp = `${file}.tmp`;
29
+ writeFileSync(tmp, JSON.stringify(info, null, 2) + "\n", { mode: 0o600 });
30
+ renameSync(tmp, file);
31
+ }
32
+ /** 读取并校验。文件不存在 / 损坏 / 进程不在 → 返回 null。 */
33
+ export function readPidfile(configPath) {
34
+ const file = pidfilePath(configPath);
35
+ if (!existsSync(file))
36
+ return null;
37
+ let raw;
38
+ try {
39
+ raw = readFileSync(file, "utf8");
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(raw);
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ if (!parsed || typeof parsed !== "object")
52
+ return null;
53
+ const info = parsed;
54
+ if (typeof info.pid !== "number" ||
55
+ typeof info.version !== "string" ||
56
+ typeof info.startedAt !== "number" ||
57
+ typeof info.url !== "string" ||
58
+ typeof info.scheme !== "string" ||
59
+ typeof info.bindAddr !== "string" ||
60
+ typeof info.configPath !== "string" ||
61
+ typeof info.dbPath !== "string" ||
62
+ typeof info.socket !== "string") {
63
+ return null;
64
+ }
65
+ return info;
66
+ }
67
+ /** 通过 `kill 0` 判断 PID 是否还活着(不发送信号,只检查存在性)。 */
68
+ export function isPidAlive(pid) {
69
+ if (!Number.isFinite(pid) || pid <= 0)
70
+ return false;
71
+ try {
72
+ process.kill(pid, 0);
73
+ return true;
74
+ }
75
+ catch (err) {
76
+ // ESRCH = no such process;EPERM = 存在但无权限发信号,仍然算活着
77
+ const code = err.code;
78
+ return code === "EPERM";
79
+ }
80
+ }
81
+ export function removePidfile(configPath) {
82
+ try {
83
+ unlinkSync(pidfilePath(configPath));
84
+ }
85
+ catch {
86
+ /* noop */
87
+ }
88
+ }
89
+ export function removeSocketFile(configPath) {
90
+ const p = socketPath(configPath);
91
+ if (!p)
92
+ return;
93
+ try {
94
+ unlinkSync(p);
95
+ }
96
+ catch {
97
+ /* noop */
98
+ }
99
+ }
100
+ /** 读取 pidfile 并校验进程是否还活着。Stale 文件会自动清理。 */
101
+ export function readLiveInstance(configPath) {
102
+ const info = readPidfile(configPath);
103
+ if (!info)
104
+ return null;
105
+ if (info.pid === process.pid)
106
+ return null; // 自身(防御性)
107
+ if (!isPidAlive(info.pid)) {
108
+ // stale,顺手清理
109
+ removePidfile(configPath);
110
+ removeSocketFile(configPath);
111
+ return null;
112
+ }
113
+ // 进程活着但 socket 不在 → 多半是异常崩溃后被某种监控拉起。视为不可用。
114
+ if (info.socket && !existsSync(info.socket))
115
+ return null;
116
+ return info;
117
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Attach 模式 TUI:当本机已有 wand 主进程在跑时,新启动的 `wand web` 会进入此模式。
3
+ *
4
+ * 数据来源:通过 wand.sock IPC 拉 snapshot(每秒一次),渲染同一套 layout。
5
+ * 日志面板:因为日志在主进程里,attach 端没法直接看;这里改成"活动流"——
6
+ * 监听 snapshot 差分,把会话起止 / 总数变化打到 log 面板。
7
+ */
8
+ import { PidInfo } from "../pidfile.js";
9
+ export interface AttachTuiDeps {
10
+ pidInfo: PidInfo;
11
+ configPath: string;
12
+ /** 退出时调用。 */
13
+ onExit: () => void | Promise<void>;
14
+ }
15
+ export interface AttachTuiHandle {
16
+ stop(): Promise<void>;
17
+ }
18
+ export declare function startAttachTui(deps: AttachTuiDeps): AttachTuiHandle;
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Attach 模式 TUI:当本机已有 wand 主进程在跑时,新启动的 `wand web` 会进入此模式。
3
+ *
4
+ * 数据来源:通过 wand.sock IPC 拉 snapshot(每秒一次),渲染同一套 layout。
5
+ * 日志面板:因为日志在主进程里,attach 端没法直接看;这里改成"活动流"——
6
+ * 监听 snapshot 差分,把会话起止 / 总数变化打到 log 面板。
7
+ */
8
+ import { checkUpdate, copyToClipboard, installService, installUpdate, isServiceInstalled, openInBrowser, uninstallService, } from "./commands.js";
9
+ import { spawnSync } from "node:child_process";
10
+ import { IpcClient } from "./ipc-client.js";
11
+ import { buildLayout } from "./layout.js";
12
+ import { openServicePanel } from "./service-panel.js";
13
+ const POLL_INTERVAL_MS = 1000;
14
+ export function startAttachTui(deps) {
15
+ const layout = buildLayout();
16
+ let active = true;
17
+ let stopping = false;
18
+ const client = new IpcClient(deps.pidInfo.socket);
19
+ client.start();
20
+ // 渲染一个"等连接"的初始 header
21
+ const placeholderHeader = {
22
+ version: deps.pidInfo.version,
23
+ url: deps.pidInfo.url,
24
+ scheme: deps.pidInfo.scheme,
25
+ bindAddr: deps.pidInfo.bindAddr,
26
+ configPath: deps.pidInfo.configPath,
27
+ dbPath: deps.pidInfo.dbPath,
28
+ orphanRecoveredCount: 0,
29
+ sessionCounts: { active: 0, archived: 0, total: 0 },
30
+ startedAtMs: deps.pidInfo.startedAt,
31
+ rssBytes: 0,
32
+ serviceInstalled: safeServiceInstalled(),
33
+ };
34
+ layout.refreshHeader(placeholderHeader);
35
+ layout.refreshSessions([]);
36
+ appendActivity(`已 attach 到主进程 PID ${deps.pidInfo.pid}`);
37
+ appendActivity(`URL: ${deps.pidInfo.url}`);
38
+ appendActivity("正在连接 IPC 套接字…", "info");
39
+ client.on("connect", () => {
40
+ appendActivity("IPC 已连接,开始轮询 snapshot", "info");
41
+ void pollOnce();
42
+ });
43
+ client.on("disconnect", () => {
44
+ appendActivity("IPC 已断开,等待重连…", "warn");
45
+ });
46
+ client.on("error", (err) => {
47
+ appendActivity(`IPC 错误: ${err.message}`, "error");
48
+ });
49
+ let lastSessionsKey = "";
50
+ let lastTotal = -1;
51
+ async function pollOnce() {
52
+ if (!active)
53
+ return;
54
+ try {
55
+ const snap = await client.snapshot();
56
+ applySnapshot(snap);
57
+ }
58
+ catch (err) {
59
+ // 静默失败:断开后会自动重连,下个 tick 会再试
60
+ if (err.message !== "ipc not connected" && err.message !== "ipc disconnected") {
61
+ appendActivity(`snapshot 失败: ${err.message}`, "warn");
62
+ }
63
+ }
64
+ }
65
+ function applySnapshot(snap) {
66
+ const h = snap.header;
67
+ const header = {
68
+ version: h.version,
69
+ url: h.url,
70
+ scheme: h.scheme,
71
+ bindAddr: h.bindAddr,
72
+ configPath: h.configPath,
73
+ dbPath: h.dbPath,
74
+ orphanRecoveredCount: h.orphanRecoveredCount,
75
+ sessionCounts: h.sessionCounts,
76
+ startedAtMs: h.startedAtMs,
77
+ rssBytes: h.rssBytes,
78
+ serviceInstalled: safeServiceInstalled(),
79
+ };
80
+ layout.refreshHeader(header);
81
+ layout.refreshSessions(snap.sessions);
82
+ // 活动流:会话集合变化时输出一行
83
+ const key = snap.sessions.map((s) => `${s.id}:${s.state}`).join("|");
84
+ if (key !== lastSessionsKey) {
85
+ diffActivity(snap.sessions);
86
+ lastSessionsKey = key;
87
+ }
88
+ if (h.sessionCounts.total !== lastTotal) {
89
+ if (lastTotal !== -1) {
90
+ appendActivity(`会话计数: ${h.sessionCounts.active} active · ${h.sessionCounts.archived} archived · ${h.sessionCounts.total} total`, "info");
91
+ }
92
+ lastTotal = h.sessionCounts.total;
93
+ }
94
+ }
95
+ let lastRowState = new Map();
96
+ function diffActivity(rows) {
97
+ const next = new Map();
98
+ for (const r of rows)
99
+ next.set(r.id, r.state);
100
+ // 新增
101
+ for (const [id, state] of next) {
102
+ if (!lastRowState.has(id)) {
103
+ appendActivity(`+ ${id.slice(0, 8)} ${state}`, "info");
104
+ }
105
+ else if (lastRowState.get(id) !== state) {
106
+ appendActivity(`~ ${id.slice(0, 8)} ${lastRowState.get(id)} → ${state}`, "info");
107
+ }
108
+ }
109
+ // 离开
110
+ for (const [id, state] of lastRowState) {
111
+ if (!next.has(id))
112
+ appendActivity(`- ${id.slice(0, 8)} ${state}`, "info");
113
+ }
114
+ lastRowState = next;
115
+ }
116
+ function appendActivity(line, level = "info") {
117
+ layout.appendLog({ level, line: `[attach] ${line}`, ts: Date.now() });
118
+ }
119
+ const pollTimer = setInterval(() => { void pollOnce(); }, POLL_INTERVAL_MS);
120
+ pollTimer.unref?.();
121
+ // —— 键位 —— 服务面板打开时,屏幕级快捷键让位
122
+ const idle = () => !layout.isServicePanelOpen();
123
+ layout.screen.key(["q", "Q"], () => { if (idle())
124
+ void stop(); });
125
+ layout.screen.key(["C-c"], () => { void stop(); });
126
+ layout.screen.key(["r"], () => {
127
+ if (!idle())
128
+ return;
129
+ void pollOnce();
130
+ layout.showToast("已请求刷新", "info", 1200);
131
+ });
132
+ layout.screen.key(["l", "L"], () => { if (idle())
133
+ layout.clearLogs(); });
134
+ layout.screen.key(["?", "h", "H"], () => { if (idle())
135
+ layout.toggleHelp(); });
136
+ // 运维快捷键 — 与本地模式行为一致,但 R 走"重启系统服务"路径
137
+ layout.screen.key(["g", "G"], () => { if (idle())
138
+ openServicePanel({ layout, configPath: deps.configPath }); });
139
+ layout.screen.key(["S-r"], () => { if (idle())
140
+ void handleRestart(); });
141
+ layout.screen.key(["u", "U"], () => { if (idle())
142
+ void handleUpdate(); });
143
+ layout.screen.key(["o", "O"], () => {
144
+ if (!idle())
145
+ return;
146
+ const r = openInBrowser(deps.pidInfo.url);
147
+ layout.showToast(r.message, r.ok ? "success" : "error", 2500);
148
+ });
149
+ layout.screen.key(["c", "C"], () => {
150
+ if (!idle())
151
+ return;
152
+ const r = copyToClipboard(deps.pidInfo.url);
153
+ layout.showToast(r.message, r.ok ? "success" : "error", 2500);
154
+ });
155
+ layout.screen.key(["s"], () => { if (idle())
156
+ void handleInstallService(); });
157
+ layout.screen.key(["S-s"], () => { if (idle())
158
+ void handleUninstallService(); });
159
+ async function handleRestart() {
160
+ const installed = safeServiceInstalled();
161
+ if (installed && process.platform === "linux") {
162
+ const ok = await layout.confirm({
163
+ title: "重启 wand 服务",
164
+ body: "将执行 systemctl --user restart wand.service。当前 attach 会话会随主进程重启被踢掉。",
165
+ });
166
+ if (!ok)
167
+ return;
168
+ layout.showToast("systemctl --user restart wand.service …", "info", 3000);
169
+ const r = spawnSync("systemctl", ["--user", "restart", "wand.service"], { encoding: "utf8" });
170
+ if (r.status === 0) {
171
+ layout.showToast("已请求重启,IPC 会自动重连", "success", 3000);
172
+ }
173
+ else {
174
+ layout.showToast(`systemctl 失败 (exit ${r.status})`, "error", 4000);
175
+ layout.showDetail("systemctl 输出", (r.stdout || "") + "\n" + (r.stderr || ""));
176
+ }
177
+ return;
178
+ }
179
+ if (installed && process.platform === "darwin") {
180
+ const ok = await layout.confirm({
181
+ title: "重启 wand 服务",
182
+ body: "将依次 launchctl unload / load com.wand.web。",
183
+ });
184
+ if (!ok)
185
+ return;
186
+ const plist = `${process.env.HOME}/Library/LaunchAgents/com.wand.web.plist`;
187
+ const u = spawnSync("launchctl", ["unload", plist], { encoding: "utf8" });
188
+ const l = spawnSync("launchctl", ["load", plist], { encoding: "utf8" });
189
+ const ok2 = u.status === 0 && l.status === 0;
190
+ layout.showToast(ok2 ? "已请求 launchd 重启" : "launchctl 调用失败", ok2 ? "success" : "error", 3500);
191
+ return;
192
+ }
193
+ // 没注册成服务:尝试通过 IPC 让主进程自我退出,再由用户手动重启
194
+ const ok = await layout.confirm({
195
+ title: "主进程未注册为系统服务",
196
+ body: "无法自动重启。要请求主进程关闭吗?关闭后请手动 `wand web` 重启。",
197
+ });
198
+ if (!ok)
199
+ return;
200
+ const accepted = await client.shutdownDaemon();
201
+ layout.showToast(accepted ? "已请求主进程退出" : "请求未被接受", accepted ? "success" : "warn", 3500);
202
+ }
203
+ async function handleUpdate() {
204
+ layout.showToast("正在检查更新…", "info", 2000);
205
+ const info = await runOffMicrotask(() => checkUpdate(deps.pidInfo.version));
206
+ if (!info.latest) {
207
+ layout.showToast("无法连接到 npm registry", "error", 3500);
208
+ return;
209
+ }
210
+ if (!info.hasUpdate) {
211
+ layout.showToast(`已是最新版本 (v${info.current})`, "success", 3000);
212
+ return;
213
+ }
214
+ const go = await layout.confirm({
215
+ title: "发现新版本",
216
+ body: `当前 v${info.current} → 最新 v${info.latest},立即升级?升级后请按 R 重启服务。`,
217
+ yes: "回车 / y 安装",
218
+ no: "Esc / n 取消",
219
+ });
220
+ if (!go)
221
+ return;
222
+ layout.showToast("正在执行 npm install -g …", "info", 5000);
223
+ const r = await runOffMicrotask(() => installUpdate());
224
+ layout.showToast(r.message, r.ok ? "success" : "error", 5000);
225
+ if (r.detail)
226
+ layout.showDetail(r.ok ? "更新输出" : "更新失败", r.detail);
227
+ }
228
+ async function handleInstallService() {
229
+ if (isServiceInstalled()) {
230
+ layout.showToast("服务已安装,按 Shift+S 卸载", "warn", 2500);
231
+ return;
232
+ }
233
+ const ok = await layout.confirm({
234
+ title: "注册为系统服务",
235
+ body: process.platform === "linux"
236
+ ? "将写入 ~/.config/systemd/user/wand.service。"
237
+ : process.platform === "darwin"
238
+ ? "将写入 ~/Library/LaunchAgents/com.wand.web.plist。"
239
+ : "当前平台暂不支持。",
240
+ });
241
+ if (!ok)
242
+ return;
243
+ const r = await runOffMicrotask(() => installService({ configPath: deps.configPath }));
244
+ layout.showToast(r.message, r.ok ? "success" : "error", 5000);
245
+ if (r.detail)
246
+ layout.showDetail(r.ok ? "服务安装详情" : "服务安装失败", r.detail);
247
+ }
248
+ async function handleUninstallService() {
249
+ if (!isServiceInstalled()) {
250
+ layout.showToast("当前未安装系统服务", "warn", 2500);
251
+ return;
252
+ }
253
+ const ok = await layout.confirm({
254
+ title: "卸载系统服务",
255
+ body: "将禁用并删除 wand 的 systemd / launchd 配置,确认继续?",
256
+ });
257
+ if (!ok)
258
+ return;
259
+ const r = await runOffMicrotask(() => uninstallService());
260
+ layout.showToast(r.message, r.ok ? "success" : "error", 4000);
261
+ if (r.detail)
262
+ layout.showDetail(r.ok ? "服务卸载详情" : "服务卸载失败", r.detail);
263
+ }
264
+ async function stop() {
265
+ if (stopping || !active)
266
+ return;
267
+ stopping = true;
268
+ active = false;
269
+ clearInterval(pollTimer);
270
+ try {
271
+ client.close();
272
+ }
273
+ catch { /* noop */ }
274
+ try {
275
+ layout.destroy();
276
+ }
277
+ catch { /* destroyed */ }
278
+ try {
279
+ await deps.onExit();
280
+ }
281
+ catch (err) {
282
+ process.stderr.write(`[wand] attach TUI 退出回调失败: ${String(err)}\n`);
283
+ }
284
+ }
285
+ return { stop };
286
+ }
287
+ function safeServiceInstalled() {
288
+ try {
289
+ return isServiceInstalled();
290
+ }
291
+ catch {
292
+ return false;
293
+ }
294
+ }
295
+ function runOffMicrotask(fn) {
296
+ return new Promise((resolve, reject) => {
297
+ setImmediate(() => {
298
+ try {
299
+ resolve(fn());
300
+ }
301
+ catch (err) {
302
+ reject(err);
303
+ }
304
+ });
305
+ });
306
+ }