@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.
- package/dist/build-info.json +6 -0
- package/dist/npm-update-utils.d.ts +10 -0
- package/dist/npm-update-utils.js +42 -6
- package/dist/relaunch.d.ts +28 -0
- package/dist/relaunch.js +17 -0
- package/dist/server.js +276 -38
- package/dist/service-self-repair.d.ts +25 -0
- package/dist/service-self-repair.js +43 -0
- package/dist/tui/attach.js +7 -0
- package/dist/tui/commands.d.ts +8 -0
- package/dist/tui/commands.js +20 -3
- package/dist/tui/index.js +7 -0
- package/dist/web-ui/content/scripts.js +97 -35
- package/package.json +2 -2
|
@@ -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";
|
package/dist/npm-update-utils.js
CHANGED
|
@@ -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
|
-
|
|
124
|
+
// 卸载用固定包名 PACKAGE_NAME,而不是从 install spec 反推:spec 可能是 git
|
|
125
|
+
// 形式(`github:co0ontty/wand#beta`),用正则 strip @tag 反推会得到错误的卸载目标。
|
|
124
126
|
try {
|
|
125
|
-
await execAsync(`npm uninstall -g ${
|
|
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
|
-
|
|
165
|
-
|
|
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;
|
package/dist/relaunch.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
1404
|
-
|
|
1405
|
-
|
|
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
|
-
|
|
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: `已更新到 ${
|
|
1592
|
+
message: `已更新到 ${targetLabel}`,
|
|
1413
1593
|
restartRequired: true,
|
|
1414
|
-
version:
|
|
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
|
-
|
|
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
|
|
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}
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/tui/attach.js
CHANGED
|
@@ -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()) {
|
package/dist/tui/commands.d.ts
CHANGED
|
@@ -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
|
/** 是否已安装服务文件。 */
|
package/dist/tui/commands.js
CHANGED
|
@@ -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
|
|
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
|
|
90
|
-
//
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
//
|
|
1567
|
-
//
|
|
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 (
|
|
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
|
-
|
|
9869
|
+
writeStoredBoolean("wand-sidebar-collapsed", false);
|
|
9846
9870
|
}
|
|
9847
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|