@co0ontty/wand 1.40.1 → 1.41.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.
@@ -0,0 +1,6 @@
1
+ {
2
+ "commit": "7881848a909b65c2a17ee9d443514110e2e23f1f",
3
+ "builtAt": "2026-05-30T15:40:31.007Z",
4
+ "version": "1.41.1",
5
+ "channel": "stable"
6
+ }
@@ -48,4 +48,14 @@ export declare function installPackageGloballySync(pkg: string, timeoutMs: numbe
48
48
  stderr: string;
49
49
  attempts: string[];
50
50
  };
51
+ /**
52
+ * 解析「刚装好的全局 wand CLI 入口」(dist/cli.js) 的绝对路径。
53
+ *
54
+ * 用途:应用内更新装完新包后,要把 systemd/launchd unit 的 ExecStart 钉到这个
55
+ * 全局安装,而不是 process.argv[1](源码安装场景下 argv[1] 是旧源码路径)。
56
+ *
57
+ * 优先 `npm root -g`/@co0ontty/wand/dist/cli.js(最准确);失败回退 `which wand`
58
+ * (npm 全局 bin 里的符号链接,node 跟随软链一样能跑)。都找不到返回 null。
59
+ */
60
+ export declare function resolveGlobalWandCli(): string | null;
51
61
  export declare const NPM_UPDATE_PACKAGE_NAME = "@co0ontty/wand";
@@ -14,6 +14,7 @@
14
14
  import { exec, spawnSync } from "node:child_process";
15
15
  import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
16
16
  import path from "node:path";
17
+ import process from "node:process";
17
18
  import { promisify } from "node:util";
18
19
  const execAsync = promisify(exec);
19
20
  const PACKAGE_NAME = "@co0ontty/wand";
@@ -120,9 +121,10 @@ export async function installPackageGloballyAsync(pkg, timeoutMs, log) {
120
121
  note(`[wand] 重试仍失败,尝试先卸载再强制安装...`);
121
122
  }
122
123
  // 终极兜底:uninstall + force install
123
- const baseName = pkg.replace(/@[^@/]*$/, ""); // strip @latest / @1.2.3
124
+ // 卸载用固定包名 PACKAGE_NAME,而不是从 install spec 反推:spec 可能是 git
125
+ // 形式(`github:co0ontty/wand#beta`),用正则 strip @tag 反推会得到错误的卸载目标。
124
126
  try {
125
- await execAsync(`npm uninstall -g ${baseName}`, { timeout: timeoutMs });
127
+ await execAsync(`npm uninstall -g ${PACKAGE_NAME}`, { timeout: timeoutMs });
126
128
  }
127
129
  catch {
128
130
  /* 卸载失败也继续,下一步 --force 可能仍然能装上 */
@@ -160,12 +162,46 @@ export function installPackageGloballySync(pkg, timeoutMs) {
160
162
  return { ...res, attempts };
161
163
  if (!hitENOTEMPTY(res))
162
164
  return { ...res, attempts };
163
- // 终极兜底
164
- const baseName = pkg.replace(/@[^@/]*$/, "");
165
- attempts.push(`npm uninstall -g ${baseName}`);
166
- spawnSync("npm", ["uninstall", "-g", baseName], { encoding: "utf8", timeout: timeoutMs });
165
+ // 终极兜底(卸载用固定包名,兼容 git spec,见 async 版同样注释)
166
+ attempts.push(`npm uninstall -g ${PACKAGE_NAME}`);
167
+ spawnSync("npm", ["uninstall", "-g", PACKAGE_NAME], { encoding: "utf8", timeout: timeoutMs });
167
168
  cleanupNpmLeftovers();
168
169
  res = tryInstall(["--force"]);
169
170
  return { ...res, attempts };
170
171
  }
172
+ /**
173
+ * 解析「刚装好的全局 wand CLI 入口」(dist/cli.js) 的绝对路径。
174
+ *
175
+ * 用途:应用内更新装完新包后,要把 systemd/launchd unit 的 ExecStart 钉到这个
176
+ * 全局安装,而不是 process.argv[1](源码安装场景下 argv[1] 是旧源码路径)。
177
+ *
178
+ * 优先 `npm root -g`/@co0ontty/wand/dist/cli.js(最准确);失败回退 `which wand`
179
+ * (npm 全局 bin 里的符号链接,node 跟随软链一样能跑)。都找不到返回 null。
180
+ */
181
+ export function resolveGlobalWandCli() {
182
+ const root = getNpmGlobalRoot();
183
+ if (root) {
184
+ const cli = path.join(root, PACKAGE_SCOPE, PACKAGE_BASENAME, "dist", "cli.js");
185
+ try {
186
+ if (existsSync(cli))
187
+ return cli;
188
+ }
189
+ catch {
190
+ /* ignore */
191
+ }
192
+ }
193
+ try {
194
+ const tool = process.platform === "win32" ? "where" : "which";
195
+ const r = spawnSync(tool, ["wand"], { encoding: "utf8", timeout: 10_000 });
196
+ if (r.status === 0) {
197
+ const first = (r.stdout || "").split(/\r?\n/).find((line) => line.trim().length > 0);
198
+ if (first)
199
+ return first.trim();
200
+ }
201
+ }
202
+ catch {
203
+ /* ignore */
204
+ }
205
+ return null;
206
+ }
171
207
  export const NPM_UPDATE_PACKAGE_NAME = PACKAGE_NAME;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * 重启策略(纯计算,无项目内依赖,便于被 server / TUI / commands 共用而不引入循环)。
3
+ *
4
+ * 背景:历史上三处重启(/api/restart、performAutoUpdate、TUI restartSelf)都无脑
5
+ * `spawn(node, process.argv.slice(1), {detached}) → exit(0)`。这在两种场景下有坑:
6
+ * 1. 应用内更新后 argv[1] 可能仍指向旧源码路径 → 重启跑回旧二进制;
7
+ * 2. systemd 托管时,exit 本身就会触发 Restart=always 重启;再 spawn 一个 detached
8
+ * 子进程会和 systemd 抢单实例 pidfile,旧 argv 的子进程可能先赢。
9
+ */
10
+ export interface RelaunchPlan {
11
+ /** "exit-only" = 仅退出,交由进程管理器(systemd Restart=always)拉起。 */
12
+ mode: "exit-only" | "spawn";
13
+ /** spawn 模式下要执行的 CLI 入口(全局 dist/cli.js 或 argv[1])。 */
14
+ bin?: string;
15
+ /** spawn 模式下传给 CLI 的参数(不含 node 与 bin),即 process.argv.slice(2)。 */
16
+ args?: string[];
17
+ }
18
+ /**
19
+ * 计算重启方式。
20
+ *
21
+ * - systemd 托管(存在 INVOCATION_ID)且已装服务 → "exit-only":仅退出,由 unit 里
22
+ * (更新自修复后可能刚被重写的)ExecStart 重新拉起,避免 spawn 抢 pidfile 的竞态。
23
+ * - 否则 → "spawn":bin 优先用刚装好的全局 CLI(更新后能跑到新版),回退 argv[1]。
24
+ */
25
+ export declare function computeRelaunch(opts: {
26
+ serviceInstalled: boolean;
27
+ globalCli: string | null;
28
+ }): RelaunchPlan;
@@ -0,0 +1,17 @@
1
+ import process from "node:process";
2
+ /**
3
+ * 计算重启方式。
4
+ *
5
+ * - systemd 托管(存在 INVOCATION_ID)且已装服务 → "exit-only":仅退出,由 unit 里
6
+ * (更新自修复后可能刚被重写的)ExecStart 重新拉起,避免 spawn 抢 pidfile 的竞态。
7
+ * - 否则 → "spawn":bin 优先用刚装好的全局 CLI(更新后能跑到新版),回退 argv[1]。
8
+ */
9
+ export function computeRelaunch(opts) {
10
+ const managedBySystemd = !!process.env.INVOCATION_ID;
11
+ if (managedBySystemd && opts.serviceInstalled) {
12
+ return { mode: "exit-only" };
13
+ }
14
+ const bin = opts.globalCli ?? process.argv[1] ?? "";
15
+ const args = process.argv.slice(2);
16
+ return { mode: "spawn", bin, args };
17
+ }
package/dist/server.js CHANGED
@@ -21,7 +21,10 @@ import { SessionLogger } from "./session-logger.js";
21
21
  import { StructuredSessionManager } from "./structured-session-manager.js";
22
22
  import { generatePwaManifest, generateServiceWorker } from "./pwa.js";
23
23
  import { getErrorMessage, registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
24
- import { installPackageGloballyAsync } from "./npm-update-utils.js";
24
+ import { installPackageGloballyAsync, resolveGlobalWandCli } from "./npm-update-utils.js";
25
+ import { repairServiceUnitAfterUpdate } from "./service-self-repair.js";
26
+ import { computeRelaunch } from "./relaunch.js";
27
+ import { isServiceInstalled } from "./tui/commands.js";
25
28
  import { registerUploadRoutes } from "./upload-routes.js";
26
29
  import { optimizePrompt, PromptOptimizeError } from "./prompt-optimizer.js";
27
30
  import { resolveDatabasePath, WandStorage } from "./storage.js";
@@ -115,6 +118,72 @@ function compareSemver(a, b) {
115
118
  }
116
119
  return 0;
117
120
  }
121
+ /** 读取 dist/build-info.json(由 scripts/stamp-build-info.js 在 build 时生成)。 */
122
+ function readBuildInfo() {
123
+ try {
124
+ const raw = readFileSync(path.join(SERVER_MODULE_DIR, "build-info.json"), "utf8");
125
+ const j = JSON.parse(raw);
126
+ return {
127
+ commit: typeof j.commit === "string" && j.commit ? j.commit : null,
128
+ builtAt: typeof j.builtAt === "string" && j.builtAt ? j.builtAt : null,
129
+ version: typeof j.version === "string" && j.version ? j.version : null,
130
+ channel: typeof j.channel === "string" && j.channel ? j.channel : null,
131
+ };
132
+ }
133
+ catch {
134
+ // dev(tsx 跑 src/)或老版本(无此文件)时降级为全 null。
135
+ return { commit: null, builtAt: null, version: null, channel: null };
136
+ }
137
+ }
138
+ const BUILD_INFO = readBuildInfo();
139
+ // owner/repo(从 PKG_REPO_URL 派生),用于拼 beta 分支 raw 地址与 git 安装 spec。
140
+ const PKG_REPO_SLUG = PKG_REPO_URL.replace(/^https?:\/\/github\.com\//, "").replace(/\.git$/, "");
141
+ const BETA_BRANCH = "beta";
142
+ const BETA_BUILD_INFO_URL = `https://raw.githubusercontent.com/${PKG_REPO_SLUG}/${BETA_BRANCH}/dist/build-info.json`;
143
+ const BETA_INSTALL_SPEC = `github:${PKG_REPO_SLUG}#${BETA_BRANCH}`;
144
+ let cachedBetaInfo = null;
145
+ let betaCacheTs = 0;
146
+ /**
147
+ * 通过 beta 分支的 dist/build-info.json 判定 beta 更新。
148
+ * 比对本地构建 commit 与远端 commit(两者记录的都是「构建源自的 master commit」)。
149
+ * 取不到远端 → updateAvailable=false(不冒进)。
150
+ */
151
+ async function checkBetaUpdate(forceRefresh = false) {
152
+ const now = Date.now();
153
+ if (!forceRefresh && cachedBetaInfo && now - betaCacheTs < CACHE_TTL_MS) {
154
+ return cachedBetaInfo;
155
+ }
156
+ const localCommit = BUILD_INFO.commit;
157
+ let remoteCommit = null;
158
+ let remoteBuiltAt = null;
159
+ try {
160
+ const resp = await fetch(BETA_BUILD_INFO_URL, {
161
+ headers: { "User-Agent": "wand-server", Accept: "application/json" },
162
+ signal: AbortSignal.timeout(10000),
163
+ });
164
+ if (resp.ok) {
165
+ const j = (await resp.json());
166
+ remoteCommit = typeof j.commit === "string" && j.commit ? j.commit : null;
167
+ remoteBuiltAt = typeof j.builtAt === "string" && j.builtAt ? j.builtAt : null;
168
+ }
169
+ }
170
+ catch {
171
+ /* 网络失败:保持 null,下面判定为无更新 */
172
+ }
173
+ const short = (c) => (c ? c.slice(0, 7) : "unknown");
174
+ const info = {
175
+ channel: "beta",
176
+ current: short(localCommit),
177
+ latest: short(remoteCommit),
178
+ updateAvailable: !!remoteCommit && remoteCommit !== localCommit,
179
+ localCommit,
180
+ remoteCommit,
181
+ remoteBuiltAt,
182
+ };
183
+ cachedBetaInfo = info;
184
+ betaCacheTs = now;
185
+ return info;
186
+ }
118
187
  let cachedGitHubApk = null;
119
188
  let gitHubApkCacheTs = 0;
120
189
  const GITHUB_APK_CACHE_TTL = 10 * 60 * 1000; // 10 minutes
@@ -412,6 +481,56 @@ function verifyAppToken(token, password, secret) {
412
481
  function encodeConnectCode(url, token) {
413
482
  return Buffer.from(`${url}#${token}`).toString("base64");
414
483
  }
484
+ function firstHeaderValue(value) {
485
+ if (Array.isArray(value))
486
+ return value[0];
487
+ return value;
488
+ }
489
+ function firstHeaderListValue(value) {
490
+ return firstHeaderValue(value)?.split(",")[0]?.trim();
491
+ }
492
+ function unquoteHeaderValue(value) {
493
+ const trimmed = value.trim();
494
+ if (trimmed.length >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
495
+ return trimmed.slice(1, -1);
496
+ }
497
+ return trimmed;
498
+ }
499
+ function getForwardedParam(req, key) {
500
+ const forwarded = firstHeaderListValue(req.headers.forwarded);
501
+ if (!forwarded)
502
+ return undefined;
503
+ const targetKey = key.toLowerCase();
504
+ for (const part of forwarded.split(";")) {
505
+ const eqIdx = part.indexOf("=");
506
+ if (eqIdx < 1)
507
+ continue;
508
+ const partKey = part.slice(0, eqIdx).trim().toLowerCase();
509
+ if (partKey !== targetKey)
510
+ continue;
511
+ return unquoteHeaderValue(part.slice(eqIdx + 1));
512
+ }
513
+ return undefined;
514
+ }
515
+ function normalizePublicProtocol(value) {
516
+ const proto = value?.trim().toLowerCase();
517
+ if (proto === "http" || proto === "https")
518
+ return proto;
519
+ return undefined;
520
+ }
521
+ function getPublicRequestProtocol(req, fallback) {
522
+ return (normalizePublicProtocol(firstHeaderListValue(req.headers["x-forwarded-proto"]))
523
+ ?? normalizePublicProtocol(getForwardedParam(req, "proto"))
524
+ ?? (firstHeaderListValue(req.headers["x-forwarded-ssl"])?.toLowerCase() === "on" ? "https" : undefined)
525
+ ?? (firstHeaderListValue(req.headers["x-forwarded-scheme"])?.toLowerCase() === "https" ? "https" : undefined)
526
+ ?? fallback);
527
+ }
528
+ function getPublicRequestHost(req, config) {
529
+ return (firstHeaderListValue(req.headers["x-forwarded-host"])
530
+ ?? getForwardedParam(req, "host")
531
+ ?? req.headers.host
532
+ ?? `${config.host}:${config.port}`);
533
+ }
415
534
  function decodeConnectCode(code) {
416
535
  try {
417
536
  const decoded = Buffer.from(code, "base64").toString("utf8");
@@ -1138,6 +1257,13 @@ export async function startServer(config, configPath) {
1138
1257
  hasCert: existsSync(certPaths.keyPath) && existsSync(certPaths.certPath),
1139
1258
  updateAvailable: cachedUpdateInfo?.updateAvailable ?? false,
1140
1259
  latestVersion: cachedUpdateInfo?.latest ?? null,
1260
+ updateChannel: getUpdateChannel(),
1261
+ build: {
1262
+ commit: BUILD_INFO.commit,
1263
+ shortCommit: BUILD_INFO.commit ? BUILD_INFO.commit.slice(0, 7) : null,
1264
+ builtAt: BUILD_INFO.builtAt,
1265
+ channel: BUILD_INFO.channel,
1266
+ },
1141
1267
  autoUpdate: {
1142
1268
  web: storage.getConfigValue("autoUpdateWeb") === "true",
1143
1269
  apk: storage.getConfigValue("autoUpdateApk") === "true",
@@ -1259,8 +1385,8 @@ export async function startServer(config, configPath) {
1259
1385
  app.get("/api/app-connect-code", requireAuth, (req, res) => {
1260
1386
  const dbPassword = storage.getPassword();
1261
1387
  const effectivePassword = dbPassword ?? config.password;
1262
- const protocol = useHttps ? "https" : "http";
1263
- const host = req.headers.host || `${config.host}:${config.port}`;
1388
+ const protocol = getPublicRequestProtocol(req, useHttps ? "https" : "http");
1389
+ const host = getPublicRequestHost(req, config);
1264
1390
  const serverUrl = `${protocol}://${host}`;
1265
1391
  const appSecret = config.appSecret ?? "";
1266
1392
  const token = generateAppToken(effectivePassword, appSecret);
@@ -1383,10 +1509,31 @@ export async function startServer(config, configPath) {
1383
1509
  process.stdout.write(`${line}\n`);
1384
1510
  });
1385
1511
  }
1512
+ /** 当前更新通道:stable(npm @latest,semver)或 beta(GitHub beta 分支,按 commit)。 */
1513
+ const getUpdateChannel = () => storage.getConfigValue("updateChannel") === "beta" ? "beta" : "stable";
1386
1514
  app.get("/api/check-update", async (_req, res) => {
1387
1515
  try {
1516
+ const channel = getUpdateChannel();
1517
+ if (channel === "beta") {
1518
+ const b = await checkBetaUpdate(true);
1519
+ res.json({
1520
+ channel: "beta",
1521
+ current: b.current,
1522
+ latest: b.latest,
1523
+ updateAvailable: b.updateAvailable,
1524
+ builtAt: b.remoteBuiltAt,
1525
+ });
1526
+ return;
1527
+ }
1388
1528
  const result = await checkNpmLatestVersion(true);
1389
- res.json(result);
1529
+ // 离开 beta 边界:当前跑的是 beta 构建、但通道切回 stable 时,强制提示可更新,
1530
+ // 让用户能从 beta 装回干净的正式版(否则 semver 相等会卡在 beta 构建上)。
1531
+ const onBetaBuild = BUILD_INFO.channel === "beta";
1532
+ res.json({
1533
+ channel: "stable",
1534
+ ...result,
1535
+ updateAvailable: result.updateAvailable || onBetaBuild,
1536
+ });
1390
1537
  }
1391
1538
  catch (error) {
1392
1539
  res.status(500).json({ error: getErrorMessage(error, "检查更新失败。") });
@@ -1400,18 +1547,52 @@ export async function startServer(config, configPath) {
1400
1547
  }
1401
1548
  updateInFlight = true;
1402
1549
  try {
1403
- const { updateAvailable, latest } = await checkNpmLatestVersion(true);
1404
- if (!updateAvailable) {
1405
- res.json({ ok: true, message: "已经是最新版本。" });
1550
+ const channel = getUpdateChannel();
1551
+ let installSpec;
1552
+ let targetLabel;
1553
+ if (channel === "beta") {
1554
+ const b = await checkBetaUpdate(true);
1555
+ if (!b.updateAvailable) {
1556
+ res.json({ ok: true, message: "已是最新 Beta 构建。" });
1557
+ return;
1558
+ }
1559
+ installSpec = BETA_INSTALL_SPEC;
1560
+ targetLabel = `Beta ${b.latest}`;
1561
+ }
1562
+ else {
1563
+ const { updateAvailable, latest } = await checkNpmLatestVersion(true);
1564
+ const onBetaBuild = BUILD_INFO.channel === "beta";
1565
+ if (!updateAvailable && !onBetaBuild) {
1566
+ res.json({ ok: true, message: "已经是最新版本。" });
1567
+ return;
1568
+ }
1569
+ installSpec = `${PKG_NAME}@latest`;
1570
+ targetLabel = onBetaBuild ? "最新正式版" : latest;
1571
+ }
1572
+ // beta 走 git 安装(clone + 装依赖),比 registry tarball 慢,给足超时。
1573
+ const logLines = [];
1574
+ try {
1575
+ await installPackageGloballyAsync(installSpec, 300000, (line) => {
1576
+ logLines.push(line);
1577
+ process.stdout.write(`${line}\n`);
1578
+ });
1579
+ }
1580
+ catch (e) {
1581
+ res.status(500).json({
1582
+ error: getErrorMessage(e, "更新失败。"),
1583
+ detail: logLines.join("\n").slice(-2000),
1584
+ });
1406
1585
  return;
1407
1586
  }
1408
- await npmInstallGlobal(`${PKG_NAME}@latest`, 120000);
1587
+ // 镜像 install.sh:装完用全局安装刷新服务 unit(ExecStart/PATH),重启才会跑到新版。
1588
+ const repair = repairServiceUnitAfterUpdate(configPath);
1409
1589
  // 装包成功后告知前端可以发起重启;前端会随即调用 /api/restart 完成自动重启。
1410
1590
  res.json({
1411
1591
  ok: true,
1412
- message: `已更新到 ${latest}`,
1592
+ message: `已更新到 ${targetLabel}`,
1413
1593
  restartRequired: true,
1414
- version: latest,
1594
+ version: targetLabel,
1595
+ serviceRepair: repair.scope ? { repaired: repair.repaired, message: repair.message } : null,
1415
1596
  });
1416
1597
  }
1417
1598
  catch (error) {
@@ -1967,6 +2148,45 @@ export async function startServer(config, configPath) {
1967
2148
  wsManager.emitEvent(event);
1968
2149
  });
1969
2150
  // ── Restart endpoint (needs server + wss in scope) ──
2151
+ function safeServiceInstalled() {
2152
+ try {
2153
+ return isServiceInstalled();
2154
+ }
2155
+ catch {
2156
+ return false;
2157
+ }
2158
+ }
2159
+ /**
2160
+ * 统一的关服 + 重启。重启方式由 computeRelaunch 决定:
2161
+ * - systemd 托管且已装服务 → 仅退出,交给 Restart=always 用(更新自修复后可能刚被
2162
+ * 重写的)ExecStart 拉起,避免再 spawn detached 子进程与 systemd 抢单实例 pidfile;
2163
+ * - 否则 → spawn 一个 detached 子进程(bin 优先全局安装,确保更新后跑到新版)再退出。
2164
+ */
2165
+ function relaunchAfterShutdown() {
2166
+ const plan = computeRelaunch({
2167
+ serviceInstalled: safeServiceInstalled(),
2168
+ globalCli: resolveGlobalWandCli(),
2169
+ });
2170
+ try {
2171
+ wss.clients.forEach((client) => client.close());
2172
+ }
2173
+ catch {
2174
+ /* noop */
2175
+ }
2176
+ server.close(() => {
2177
+ if (plan.mode === "spawn") {
2178
+ spawn(process.execPath, [plan.bin ?? "", ...(plan.args ?? [])], {
2179
+ detached: true,
2180
+ stdio: "inherit",
2181
+ cwd: process.cwd(),
2182
+ env: process.env,
2183
+ }).unref();
2184
+ }
2185
+ process.exit(0);
2186
+ });
2187
+ // Force exit after 5s if graceful shutdown stalls
2188
+ setTimeout(() => process.exit(0), 5000);
2189
+ }
1970
2190
  app.post("/api/restart", async (_req, res) => {
1971
2191
  res.json({ ok: true, message: "服务正在重启..." });
1972
2192
  wsManager.emitEvent({
@@ -1975,19 +2195,7 @@ export async function startServer(config, configPath) {
1975
2195
  data: { kind: "restart" },
1976
2196
  });
1977
2197
  setTimeout(() => {
1978
- // Close all WebSocket connections first
1979
- wss.clients.forEach((client) => client.close());
1980
- server.close(() => {
1981
- spawn(process.execPath, process.argv.slice(1), {
1982
- detached: true,
1983
- stdio: "inherit",
1984
- cwd: process.cwd(),
1985
- env: process.env,
1986
- }).unref();
1987
- process.exit(0);
1988
- });
1989
- // Force exit after 5s if graceful shutdown stalls
1990
- setTimeout(() => process.exit(0), 5000);
2198
+ relaunchAfterShutdown();
1991
2199
  }, 600);
1992
2200
  });
1993
2201
  let bindAddr = config.host === "0.0.0.0" ? "0.0.0.0" : config.host;
@@ -2045,16 +2253,45 @@ export async function startServer(config, configPath) {
2045
2253
  dmg: storage.getConfigValue("autoUpdateDmg") === "true",
2046
2254
  });
2047
2255
  });
2256
+ // ── Update channel (stable / beta) ──
2257
+ app.get("/api/update-channel", (_req, res) => {
2258
+ res.json({
2259
+ channel: getUpdateChannel(),
2260
+ build: {
2261
+ commit: BUILD_INFO.commit,
2262
+ shortCommit: BUILD_INFO.commit ? BUILD_INFO.commit.slice(0, 7) : null,
2263
+ builtAt: BUILD_INFO.builtAt,
2264
+ channel: BUILD_INFO.channel,
2265
+ },
2266
+ });
2267
+ });
2268
+ app.post("/api/update-channel", express.json(), (req, res) => {
2269
+ const body = (req.body ?? {});
2270
+ const channel = body.channel === "beta" ? "beta" : "stable";
2271
+ storage.setConfigValue("updateChannel", channel);
2272
+ res.json({ channel });
2273
+ });
2048
2274
  // ── Auto-update logic ──
2049
2275
  async function performAutoUpdate() {
2050
- const info = await checkNpmLatestVersion(true);
2276
+ const channel = getUpdateChannel();
2277
+ let info;
2278
+ let updateSpec;
2279
+ if (channel === "beta") {
2280
+ const b = await checkBetaUpdate(true);
2281
+ info = { current: b.current, latest: b.latest, updateAvailable: b.updateAvailable };
2282
+ updateSpec = BETA_INSTALL_SPEC;
2283
+ }
2284
+ else {
2285
+ info = await checkNpmLatestVersion(true);
2286
+ updateSpec = `${PKG_NAME}@latest`;
2287
+ }
2051
2288
  cachedUpdateInfo = info;
2052
2289
  if (!info.updateAvailable)
2053
2290
  return;
2054
2291
  const autoEnabled = storage.getConfigValue("autoUpdateWeb") === "true";
2055
2292
  if (!autoEnabled) {
2056
2293
  // Not auto-updating, just notify
2057
- process.stdout.write(`[wand] 发现新版本 ${info.latest}(当前 ${info.current})。运行 npm install -g ${PKG_NAME}@latest 进行更新。\n`);
2294
+ process.stdout.write(`[wand] 发现新版本 ${info.latest}(当前 ${info.current})。可在设置中更新${channel === "beta" ? "(Beta 通道)" : ""}。\n`);
2058
2295
  wsManager.emitEvent({
2059
2296
  type: "notification",
2060
2297
  sessionId: "__system__",
@@ -2070,7 +2307,11 @@ export async function startServer(config, configPath) {
2070
2307
  data: { kind: "auto-update-start", current: info.current, latest: info.latest },
2071
2308
  });
2072
2309
  try {
2073
- await npmInstallGlobal(`${PKG_NAME}@latest`, 120000);
2310
+ await npmInstallGlobal(updateSpec, 120000);
2311
+ // 镜像 install.sh:装完用全局安装刷新服务 unit(ExecStart/PATH),重启才会跑到新版。
2312
+ const repair = repairServiceUnitAfterUpdate(configPath);
2313
+ if (repair.scope)
2314
+ process.stdout.write(`[wand] ${repair.message}\n`);
2074
2315
  process.stdout.write(`[wand] 自动更新完成,正在重启...\n`);
2075
2316
  wsManager.emitEvent({
2076
2317
  type: "notification",
@@ -2079,21 +2320,18 @@ export async function startServer(config, configPath) {
2079
2320
  });
2080
2321
  // Restart after a brief delay
2081
2322
  setTimeout(() => {
2082
- wss.clients.forEach((client) => client.close());
2083
- server.close(() => {
2084
- spawn(process.execPath, process.argv.slice(1), {
2085
- detached: true,
2086
- stdio: "inherit",
2087
- cwd: process.cwd(),
2088
- env: process.env,
2089
- }).unref();
2090
- process.exit(0);
2091
- });
2092
- setTimeout(() => process.exit(0), 5000);
2323
+ relaunchAfterShutdown();
2093
2324
  }, 1000);
2094
2325
  }
2095
2326
  catch (error) {
2096
- process.stdout.write(`[wand] 自动更新失败: ${getErrorMessage(error, "未知错误")}\n`);
2327
+ const msg = getErrorMessage(error, "未知错误");
2328
+ process.stdout.write(`[wand] 自动更新失败: ${msg}\n`);
2329
+ // 失败不重启、保留旧版;通知前端,避免静默。
2330
+ wsManager.emitEvent({
2331
+ type: "notification",
2332
+ sessionId: "__system__",
2333
+ data: { kind: "auto-update-failed", current: info.current, latest: info.latest, error: msg },
2334
+ });
2097
2335
  }
2098
2336
  }
2099
2337
  // Background update check on startup
@@ -0,0 +1,25 @@
1
+ import { type ServiceScope } from "./tui/commands.js";
2
+ /**
3
+ * 应用内更新后的「服务单元自修复」,对齐 install.sh 的行为。
4
+ *
5
+ * install.sh 升级时会在 `npm install -g` 之后重跑 `wand service:install`,用当前 PATH
6
+ * 重新烧 unit、并把 ExecStart 钉到新装的全局 wand。应用内的三条更新路径
7
+ * (/api/update、performAutoUpdate、TUI installUpdate)过去缺这一步,于是源码安装的
8
+ * 用户更新到 npm/GitHub 版后,systemd unit 的 ExecStart 仍指旧源码、baked PATH 失效,
9
+ * 重启时「服务找不到 / claude 找不到」。
10
+ *
11
+ * 这里在装包成功后调用,best-effort:
12
+ * - 没装服务 → 跳过;
13
+ * - 装了服务 → 用 preferGlobalBin 重写 unit(ExecStart→全局 dist/cli.js,
14
+ * Environment=PATH 取当前已被 path-repair 修复过的 process.env.PATH)+ daemon-reload;
15
+ * - system scope 非 root 无法写 /etc → installService 返回失败,这里捕获成 warning,
16
+ * 绝不抛错中断更新流程。
17
+ */
18
+ export interface ServiceRepairResult {
19
+ /** 是否成功重写了 unit。无服务、或重写失败都为 false。 */
20
+ repaired: boolean;
21
+ scope?: ServiceScope;
22
+ /** 给日志 / 前端展示的一行说明。 */
23
+ message: string;
24
+ }
25
+ export declare function repairServiceUnitAfterUpdate(configPath: string): ServiceRepairResult;
@@ -0,0 +1,43 @@
1
+ import process from "node:process";
2
+ import { detectInstalledScope, installService } from "./tui/commands.js";
3
+ export function repairServiceUnitAfterUpdate(configPath) {
4
+ // 仅 Linux(systemd) / macOS(launchd) 有服务模型。
5
+ if (process.platform !== "linux" && process.platform !== "darwin") {
6
+ return { repaired: false, message: "当前平台无服务模型,跳过 unit 自修复" };
7
+ }
8
+ let scope = null;
9
+ try {
10
+ scope = detectInstalledScope();
11
+ }
12
+ catch {
13
+ scope = null;
14
+ }
15
+ if (!scope) {
16
+ return { repaired: false, message: "未安装系统服务,跳过 unit 自修复" };
17
+ }
18
+ try {
19
+ const result = installService({ configPath, scope, preferGlobalBin: true });
20
+ if (result.ok) {
21
+ return {
22
+ repaired: true,
23
+ scope,
24
+ message: `已用全局安装重写 ${scope} 服务 unit(刷新 ExecStart / PATH)`,
25
+ };
26
+ }
27
+ const tail = scope === "system"
28
+ ? ";系统级 unit 需要 root 才能重写,重启后可手动跑 `sudo wand service:install` 修复"
29
+ : "";
30
+ return {
31
+ repaired: false,
32
+ scope,
33
+ message: `服务 unit 重写失败(${result.message})${tail}`,
34
+ };
35
+ }
36
+ catch (err) {
37
+ return {
38
+ repaired: false,
39
+ scope,
40
+ message: `服务 unit 重写异常: ${err instanceof Error ? err.message : String(err)}`,
41
+ };
42
+ }
43
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { checkUpdate, copyToClipboard, installService, installUpdate, isServiceInstalled, openInBrowser, uninstallService, } from "./commands.js";
9
9
  import { spawnSync } from "node:child_process";
10
+ import { repairServiceUnitAfterUpdate } from "../service-self-repair.js";
10
11
  import { IpcClient } from "./ipc-client.js";
11
12
  import { buildLayout } from "./layout.js";
12
13
  import { openServicePanel } from "./service-panel.js";
@@ -224,6 +225,12 @@ export function startAttachTui(deps) {
224
225
  layout.showToast(r.message, r.ok ? "success" : "error", 5000);
225
226
  if (r.detail)
226
227
  layout.showDetail(r.ok ? "更新输出" : "更新失败", r.detail);
228
+ if (r.ok) {
229
+ // 镜像 install.sh:装完用全局安装刷新服务 unit(ExecStart/PATH),再按 R 重启服务生效。
230
+ const repair = await runOffMicrotask(() => repairServiceUnitAfterUpdate(deps.configPath));
231
+ if (repair.scope)
232
+ layout.showToast(repair.message, repair.repaired ? "success" : "warn", 4500);
233
+ }
227
234
  }
228
235
  async function handleInstallService() {
229
236
  if (isServiceInstalled()) {
@@ -53,11 +53,19 @@ export interface ServiceContext {
53
53
  wandBin?: string;
54
54
  /** 显式指定作用域。不传走 DEFAULT_SERVICE_SCOPE。 */
55
55
  scope?: ServiceScope;
56
+ /**
57
+ * 更新后自修复场景:优先把 ExecStart 钉到「全局安装的 wand」(npm root -g 下的
58
+ * dist/cli.js),而不是 process.argv[1]。源码安装的用户更新到 npm/GitHub 版后,
59
+ * argv[1] 仍是旧源码路径,沿用它会让重启跑回旧二进制。
60
+ */
61
+ preferGlobalBin?: boolean;
56
62
  }
57
63
  export interface ServiceOpts {
58
64
  /** 不传 = 自动检测已装的那个;都没装就用 default。 */
59
65
  scope?: ServiceScope;
60
66
  }
67
+ /** 自动检测哪个 scope 已经装了 unit;优先 system,找不到就 user。两个都没装返回 null。 */
68
+ export declare function detectInstalledScope(): ServiceScope | null;
61
69
  export declare function isServiceInstalled(scope?: ServiceScope): boolean;
62
70
  export interface ServiceStatus {
63
71
  /** 是否已安装服务文件。 */
@@ -9,7 +9,8 @@ import { existsSync, mkdirSync, 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";
12
- import { installPackageGloballySync } from "../npm-update-utils.js";
12
+ import { installPackageGloballySync, resolveGlobalWandCli } from "../npm-update-utils.js";
13
+ import { computeRelaunch } from "../relaunch.js";
13
14
  const PACKAGE_NAME = "@co0ontty/wand";
14
15
  // ─── 重启 ────────────────────────────────────────────────────────────────
15
16
  /**
@@ -19,7 +20,17 @@ const PACKAGE_NAME = "@co0ontty/wand";
19
20
  */
20
21
  export function restartSelf() {
21
22
  try {
22
- const child = spawn(process.execPath, process.argv.slice(1), {
23
+ const plan = computeRelaunch({
24
+ serviceInstalled: isServiceInstalled(),
25
+ globalCli: resolveGlobalWandCli(),
26
+ });
27
+ if (plan.mode === "exit-only") {
28
+ // systemd 托管:仅退出,交给 Restart=always 用 unit 里的 ExecStart 拉起,
29
+ // 避免再 spawn 一个 detached 子进程与 systemd 抢单实例 pidfile。
30
+ setTimeout(() => process.exit(0), 200);
31
+ return { ok: true, message: "重启中…交由 systemd 拉起" };
32
+ }
33
+ const child = spawn(process.execPath, [plan.bin ?? "", ...(plan.args ?? [])], {
23
34
  detached: true,
24
35
  stdio: "inherit",
25
36
  env: process.env,
@@ -136,7 +147,7 @@ function isRoot() {
136
147
  }
137
148
  }
138
149
  /** 自动检测哪个 scope 已经装了 unit;优先 system,找不到就 user。两个都没装返回 null。 */
139
- function detectInstalledScope() {
150
+ export function detectInstalledScope() {
140
151
  if (existsSync(servicePathFor("system")))
141
152
  return "system";
142
153
  if (existsSync(servicePathFor("user")))
@@ -418,6 +429,12 @@ function servicePathFor(scope) {
418
429
  return "";
419
430
  }
420
431
  function resolveWandBin(ctx) {
432
+ // 更新后自修复:优先全局安装的 dist/cli.js,避免沿用旧源码路径。
433
+ if (ctx.preferGlobalBin) {
434
+ const global = resolveGlobalWandCli();
435
+ if (global)
436
+ return global;
437
+ }
421
438
  if (ctx.wandBin && existsSync(ctx.wandBin))
422
439
  return ctx.wandBin;
423
440
  const argv1 = process.argv[1];
package/dist/tui/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { checkUpdate, copyToClipboard, installService, installUpdate, isServiceInstalled, openInBrowser, restartSelf, uninstallService, } from "./commands.js";
2
+ import { repairServiceUnitAfterUpdate } from "../service-self-repair.js";
2
3
  import { buildLayout } from "./layout.js";
3
4
  import { installLogBus, restoreLogBus } from "./log-bus.js";
4
5
  import { formatSession, sortRows } from "./session-formatter.js";
@@ -182,6 +183,12 @@ export function startTui(deps) {
182
183
  layout.showToast(r.message, r.ok ? "success" : "error", 5000);
183
184
  if (r.detail)
184
185
  layout.showDetail(r.ok ? "更新输出" : "更新失败", r.detail);
186
+ if (r.ok) {
187
+ // 镜像 install.sh:装完用全局安装刷新服务 unit(ExecStart/PATH),再按 R 重启生效。
188
+ const repair = await runOffMicrotask(() => repairServiceUnitAfterUpdate(deps.configPath));
189
+ if (repair.scope)
190
+ layout.showToast(repair.message, repair.repaired ? "success" : "warn", 4500);
191
+ }
185
192
  }
186
193
  function handleOpenBrowser() {
187
194
  const url = deps.urls[0]?.url;
@@ -86,11 +86,9 @@
86
86
  // 当前长度时整段跳过;新用户首次加载会一口气把所有 migration 都跑完再写
87
87
  // schema 号 —— 因此每个 migration 函数对「key 不存在」的输入也必须是无害的。
88
88
  var LS_MIGRATIONS = [
89
- // v1(2026-05)取消独立的「图钉」按钮,呼出侧栏即常驻。旧版残留的
90
- // wand-sidebar-pinned=false 会让老用户继续走 drawer 模式看不到新行为,
91
- // 这里直接清掉,让 state 初始化回退到默认 true。
89
+ // v1 保留为 no-op:曾经这里会删除 wand-sidebar-pinned,导致升级或刷新时
90
+ // 覆盖用户明确选择的侧栏状态。迁移函数必须只修正格式,不能抹掉偏好。
92
91
  function migrateSidebarPinDefault() {
93
- try { localStorage.removeItem("wand-sidebar-pinned"); } catch (e) {}
94
92
  }
95
93
  ];
96
94
  (function runLocalStorageMigrations() {
@@ -107,6 +105,23 @@
107
105
  } catch (e) { /* localStorage 不可用就跳过,按默认行为运行 */ }
108
106
  })();
109
107
 
108
+ function readStoredBoolean(key, defaultValue) {
109
+ try {
110
+ var value = localStorage.getItem(key);
111
+ if (value === "true") return true;
112
+ if (value === "false") return false;
113
+ return defaultValue;
114
+ } catch (e) {
115
+ return defaultValue;
116
+ }
117
+ }
118
+
119
+ function writeStoredBoolean(key, value) {
120
+ try {
121
+ localStorage.setItem(key, String(!!value));
122
+ } catch (e) {}
123
+ }
124
+
110
125
  var state = {
111
126
  selectedId: (function() {
112
127
  try { return localStorage.getItem("wand-selected-session") || null; } catch (e) { return null; }
@@ -182,18 +197,10 @@
182
197
  loginPending: false,
183
198
  loginChecked: false,
184
199
  bootstrapping: true,
185
- sessionsDrawerOpen: false,
186
- sidebarPinned: (function() {
187
- // 新交互:桌面默认呼出即常驻;只有用户主动 X 关闭过才记 "false"
188
- // 老用户的旧值("true"/"false")继续生效,没存过 key 时回退到 true。
189
- try {
190
- var v = localStorage.getItem("wand-sidebar-pinned");
191
- return v === null ? true : v !== "false";
192
- } catch (e) { return true; }
193
- })(),
194
- sidebarCollapsed: (function() {
195
- try { return localStorage.getItem("wand-sidebar-collapsed") === "true"; } catch (e) { return false; }
196
- })(),
200
+ sessionsDrawerOpen: readStoredBoolean("wand-sidebar-open", false),
201
+ // 新交互:桌面默认呼出即常驻;只有用户主动关闭过才记 "false"。
202
+ sidebarPinned: readStoredBoolean("wand-sidebar-pinned", true),
203
+ sidebarCollapsed: readStoredBoolean("wand-sidebar-collapsed", false),
197
204
  modalOpen: false,
198
205
  presetValue: "",
199
206
  cwdValue: "",
@@ -1563,9 +1570,9 @@
1563
1570
  }
1564
1571
 
1565
1572
  // ===== 桌面:点 sidebar 外的空白处自动收起 =====
1566
- // 旧版 drawer 模式下点 backdrop 关闭的便利性,在「呼出即常驻」之后用
1567
- // document 级捕获 handler 续上。
1568
- // - 仅 desktop + 全尺寸(非窄条)+ 已打开 时生效
1573
+ // 只对「临时打开但未锁定」的全尺寸侧栏生效;已锁定的 pinned 侧栏
1574
+ // 必须保持常驻,除非用户明确点 X / hamburger 关闭。
1575
+ // - 仅 desktop + 未锁定 + 全尺寸(非窄条)+ 已打开 时生效
1569
1576
  // - 窄条态不触发(窄条本来就是稳定常驻形态)
1570
1577
  // - 手机端由 .drawer-backdrop 元素自己接住点击,不在这里重复处理
1571
1578
  // - 各类弹层(modal / topbar-more / overflow 菜单 / 文件夹下拉等)不算
@@ -1573,7 +1580,7 @@
1573
1580
  // 用 capture 阶段是为了绕过下游按钮自己的 stopPropagation。
1574
1581
  document.addEventListener("click", function(e) {
1575
1582
  if (isMobileLayout()) return;
1576
- if (!state.sidebarPinned) return;
1583
+ if (state.sidebarPinned) return;
1577
1584
  if (state.sidebarCollapsed) return;
1578
1585
  if (!state.sessionsDrawerOpen) return;
1579
1586
  var target = e.target;
@@ -1615,6 +1622,7 @@
1615
1622
  // false 打架,并在手机端误触发背景遮罩。窄条态下不强制 open。
1616
1623
  if (state.sidebarPinned && !state.sidebarCollapsed && !isMobileLayout()) {
1617
1624
  state.sessionsDrawerOpen = true;
1625
+ writeStoredBoolean("wand-sidebar-open", true);
1618
1626
  }
1619
1627
  app.innerHTML = isLoggedIn ? renderAppShell() : renderLogin();
1620
1628
  // Reset chat render tracking since DOM was fully replaced
@@ -3102,6 +3110,16 @@
3102
3110
  '<button id="do-restart-button" class="btn btn-success btn-sm hidden">\u91cd\u542f\u751f\u6548</button>' +
3103
3111
  '</div>' +
3104
3112
  '<p id="update-message" class="hint hidden"></p>' +
3113
+ '<div class="settings-toggle-row">' +
3114
+ '<div class="settings-toggle-text">' +
3115
+ '<span class="settings-toggle-title">Beta 通道</span>' +
3116
+ '<span class="settings-toggle-desc">更新到最新提交构建(commit 版本),尝鲜新功能,可能不稳定。</span>' +
3117
+ '</div>' +
3118
+ '<label class="settings-switch">' +
3119
+ '<input type="checkbox" id="beta-channel-toggle" class="switch-toggle">' +
3120
+ '<span class="switch-slider"></span>' +
3121
+ '</label>' +
3122
+ '</div>' +
3105
3123
  '<div class="settings-toggle-row">' +
3106
3124
  '<div class="settings-toggle-text">' +
3107
3125
  '<span class="settings-toggle-title">自动更新</span>' +
@@ -4157,6 +4175,7 @@
4157
4175
  } catch (e) {}
4158
4176
  if (state.filePanelOpen && isMobileLayout()) {
4159
4177
  state.sessionsDrawerOpen = false;
4178
+ writeStoredBoolean("wand-sidebar-open", false);
4160
4179
  }
4161
4180
  updateLayoutState();
4162
4181
  if (state.filePanelOpen) {
@@ -6371,6 +6390,10 @@
6371
6390
  if (autoUpdateDmgToggle) autoUpdateDmgToggle.addEventListener("change", function() {
6372
6391
  toggleAutoUpdate("dmg", autoUpdateDmgToggle.checked);
6373
6392
  });
6393
+ var betaChannelToggle = document.getElementById("beta-channel-toggle");
6394
+ if (betaChannelToggle) betaChannelToggle.addEventListener("change", function() {
6395
+ setUpdateChannel(betaChannelToggle.checked ? "beta" : "stable");
6396
+ });
6374
6397
  var copyConnectCodeBtn = document.getElementById("copy-connect-code-button");
6375
6398
  if (copyConnectCodeBtn) copyConnectCodeBtn.addEventListener("click", function() {
6376
6399
  var text = document.getElementById("android-connect-code");
@@ -8644,6 +8667,7 @@
8644
8667
  state.claudeHistoryExpanded = false;
8645
8668
  state.claudeHistoryExpandedDirs = {};
8646
8669
  state.sessionsDrawerOpen = false;
8670
+ writeStoredBoolean("wand-sidebar-open", false);
8647
8671
  render();
8648
8672
  }
8649
8673
 
@@ -9842,15 +9866,17 @@
9842
9866
  if (willOpen) {
9843
9867
  // 桌面重新呼出默认回到全尺寸;窄条形态需用户主动点 collapse 按钮切换。
9844
9868
  state.sidebarCollapsed = false;
9845
- try { localStorage.setItem("wand-sidebar-collapsed", "false"); } catch (e) {}
9869
+ writeStoredBoolean("wand-sidebar-collapsed", false);
9846
9870
  }
9847
- try { localStorage.setItem("wand-sidebar-pinned", String(willOpen)); } catch (e) {}
9871
+ writeStoredBoolean("wand-sidebar-pinned", willOpen);
9872
+ writeStoredBoolean("wand-sidebar-open", state.sessionsDrawerOpen);
9848
9873
  updateLayoutState();
9849
9874
  scheduleTerminalRefitAfterPaddingTransition();
9850
9875
  return;
9851
9876
  }
9852
9877
  // 手机端:保持原 drawer 行为。
9853
9878
  state.sessionsDrawerOpen = !state.sessionsDrawerOpen;
9879
+ writeStoredBoolean("wand-sidebar-open", state.sessionsDrawerOpen);
9854
9880
  if (state.sessionsDrawerOpen) {
9855
9881
  state.filePanelOpen = false;
9856
9882
  try {
@@ -9869,7 +9895,8 @@
9869
9895
  closeSwipedItem();
9870
9896
  state.sidebarPinned = false;
9871
9897
  state.sessionsDrawerOpen = false;
9872
- try { localStorage.setItem("wand-sidebar-pinned", "false"); } catch (e) {}
9898
+ writeStoredBoolean("wand-sidebar-pinned", false);
9899
+ writeStoredBoolean("wand-sidebar-open", false);
9873
9900
  updateLayoutState();
9874
9901
  scheduleTerminalRefitAfterPaddingTransition();
9875
9902
  return;
@@ -9878,6 +9905,7 @@
9878
9905
  if (!state.sessionsDrawerOpen) return;
9879
9906
  closeSwipedItem();
9880
9907
  state.sessionsDrawerOpen = false;
9908
+ writeStoredBoolean("wand-sidebar-open", false);
9881
9909
  updateLayoutState();
9882
9910
  }
9883
9911
 
@@ -9978,14 +10006,10 @@
9978
10006
  // 任何形态下点窄条按钮都意味着「我要常驻」,确保 pinned 写上。
9979
10007
  if (!state.sidebarPinned) {
9980
10008
  state.sidebarPinned = true;
9981
- try {
9982
- localStorage.setItem("wand-sidebar-pinned", "true");
9983
- } catch (e) {}
10009
+ writeStoredBoolean("wand-sidebar-pinned", true);
9984
10010
  }
9985
10011
  state.sidebarCollapsed = !state.sidebarCollapsed;
9986
- try {
9987
- localStorage.setItem("wand-sidebar-collapsed", String(state.sidebarCollapsed));
9988
- } catch (e) {}
10012
+ writeStoredBoolean("wand-sidebar-collapsed", state.sidebarCollapsed);
9989
10013
  if (state.sidebarCollapsed) {
9990
10014
  // 进入窄条形态:sessionsDrawerOpen 设 false,避免手机上 .drawer-backdrop
9991
10015
  // 仍带 .open 类导致背景遮罩误显示(窄条已经常驻显示,不需要遮罩)。
@@ -9995,13 +10019,12 @@
9995
10019
  // 改为回到 drawer 模式并自动打开抽屉,让用户看到完整会话列表。
9996
10020
  state.sidebarPinned = false;
9997
10021
  state.sessionsDrawerOpen = true;
9998
- try {
9999
- localStorage.setItem("wand-sidebar-pinned", "false");
10000
- } catch (e) {}
10022
+ writeStoredBoolean("wand-sidebar-pinned", false);
10001
10023
  } else {
10002
10024
  // 桌面端展开窄条 → 300px 全栏常驻。
10003
10025
  state.sessionsDrawerOpen = true;
10004
10026
  }
10027
+ writeStoredBoolean("wand-sidebar-open", state.sessionsDrawerOpen);
10005
10028
  // 轻量更新而非全量 render():render() 会 teardown 并重建整个终端 DOM,
10006
10029
  // 导致收窄/展开时终端闪烁、丢失滚动与渲染状态。这里只切布局 class
10007
10030
  // (宽度 56↔300 走 CSS width transition)、重渲侧栏列表内容、刷新
@@ -10028,7 +10051,8 @@
10028
10051
  state.sidebarPinned = !state.sidebarPinned;
10029
10052
  // 关键:保持侧栏可见停靠,无论锁定与否,点图钉都不让它消失。
10030
10053
  state.sessionsDrawerOpen = true;
10031
- try { localStorage.setItem("wand-sidebar-pinned", String(state.sidebarPinned)); } catch (e) {}
10054
+ writeStoredBoolean("wand-sidebar-pinned", state.sidebarPinned);
10055
+ writeStoredBoolean("wand-sidebar-open", true);
10032
10056
  updateLayoutState();
10033
10057
  scheduleTerminalRefitAfterPaddingTransition();
10034
10058
  }
@@ -10081,6 +10105,7 @@
10081
10105
  closeSettingsModal();
10082
10106
  state.modalOpen = true;
10083
10107
  state.sessionsDrawerOpen = false;
10108
+ writeStoredBoolean("wand-sidebar-open", false);
10084
10109
  updateDrawerState();
10085
10110
  var modal = document.getElementById("session-modal");
10086
10111
  if (modal) {
@@ -10691,6 +10716,10 @@
10691
10716
  }
10692
10717
  }
10693
10718
 
10719
+ // Beta channel toggle
10720
+ var betaChannelToggle = document.getElementById("beta-channel-toggle");
10721
+ if (betaChannelToggle) betaChannelToggle.checked = data.updateChannel === "beta";
10722
+
10694
10723
  // Auto-update toggles
10695
10724
  var autoUpdate = data.autoUpdate || {};
10696
10725
  var autoUpdateWebToggle = document.getElementById("auto-update-web-toggle");
@@ -11233,12 +11262,22 @@
11233
11262
  if (latestEl) latestEl.textContent = "检查失败";
11234
11263
  return;
11235
11264
  }
11236
- if (latestEl) latestEl.textContent = data.latest;
11265
+ var isBeta = data.channel === "beta";
11266
+ if (latestEl) {
11267
+ if (isBeta) {
11268
+ var label = "beta · " + (data.latest || "unknown");
11269
+ if (data.builtAt) { try { label += " · " + timeAgo(data.builtAt); } catch (_e) {} }
11270
+ latestEl.textContent = label;
11271
+ } else {
11272
+ latestEl.textContent = data.latest;
11273
+ }
11274
+ }
11237
11275
  if (data.updateAvailable && updateBtn) {
11276
+ updateBtn.textContent = isBeta ? "更新到最新 Beta" : "更新到最新版";
11238
11277
  updateBtn.classList.remove("hidden");
11239
11278
  }
11240
11279
  if (!data.updateAvailable && msgEl) {
11241
- msgEl.textContent = "已是最新版本。";
11280
+ msgEl.textContent = isBeta ? "已是最新 Beta 构建。" : "已是最新版本。";
11242
11281
  msgEl.style.color = "var(--success)";
11243
11282
  msgEl.classList.remove("hidden");
11244
11283
  }
@@ -11369,6 +11408,26 @@
11369
11408
  });
11370
11409
  }
11371
11410
 
11411
+ function setUpdateChannel(channel) {
11412
+ fetch("/api/update-channel", {
11413
+ method: "POST",
11414
+ headers: { "Content-Type": "application/json" },
11415
+ credentials: "same-origin",
11416
+ body: JSON.stringify({ channel: channel }),
11417
+ })
11418
+ .then(function(res) { return res.json(); })
11419
+ .then(function(data) {
11420
+ var toggle = document.getElementById("beta-channel-toggle");
11421
+ if (toggle) toggle.checked = data.channel === "beta";
11422
+ // 切换通道后重新检查更新,刷新"最新版本"显示与更新按钮。
11423
+ checkForUpdate();
11424
+ })
11425
+ .catch(function() {
11426
+ var toggle = document.getElementById("beta-channel-toggle");
11427
+ if (toggle) toggle.checked = channel !== "beta";
11428
+ });
11429
+ }
11430
+
11372
11431
  // ── Notification Settings Helpers ──
11373
11432
 
11374
11433
  function _updateAppIconSelection(activeIcon) {
@@ -16771,9 +16830,11 @@
16771
16830
  lastKnownDesktop = isDesktop;
16772
16831
  if (!isDesktop && state.sidebarPinned && state.sessionsDrawerOpen) {
16773
16832
  state.sessionsDrawerOpen = false;
16833
+ writeStoredBoolean("wand-sidebar-open", false);
16774
16834
  updateDrawerState();
16775
16835
  } else if (isDesktop && state.sidebarPinned && !state.sessionsDrawerOpen) {
16776
16836
  state.sessionsDrawerOpen = true;
16837
+ writeStoredBoolean("wand-sidebar-open", true);
16777
16838
  updateDrawerState();
16778
16839
  }
16779
16840
  }
@@ -21849,6 +21910,7 @@
21849
21910
  state.selectedId = null;
21850
21911
  persistSelectedId();
21851
21912
  state.sessionsDrawerOpen = true;
21913
+ writeStoredBoolean("wand-sidebar-open", true);
21852
21914
  render();
21853
21915
  return true;
21854
21916
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "1.40.1",
3
+ "version": "1.41.1",
4
4
  "description": "A web terminal for local CLI tools like Claude.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  ],
17
17
  "preferGlobal": true,
18
18
  "scripts": {
19
- "build": "node scripts/bundle-wterm.js && node scripts/bundle-qrcode.js && tsc -p tsconfig.json && npm run build:copy-content",
19
+ "build": "node scripts/bundle-wterm.js && node scripts/bundle-qrcode.js && tsc -p tsconfig.json && npm run build:copy-content && node scripts/stamp-build-info.js",
20
20
  "build:copy-content": "cp -r src/web-ui/content dist/web-ui/",
21
21
  "dev": "tsx src/cli.ts web",
22
22
  "check": "tsc --noEmit -p tsconfig.json",