@co0ontty/wand 1.30.3 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -2
- package/dist/claude-sdk-runner.d.ts +31 -0
- package/dist/claude-sdk-runner.js +142 -0
- package/dist/cli.js +104 -0
- package/dist/git-quick-commit.js +18 -26
- package/dist/process-manager.d.ts +7 -0
- package/dist/process-manager.js +26 -2
- package/dist/prompt-optimizer.js +17 -26
- package/dist/server-session-routes.js +27 -1
- package/dist/server.js +1 -0
- package/dist/structured-session-manager.d.ts +2 -0
- package/dist/structured-session-manager.js +21 -3
- package/dist/tui/attach.js +7 -8
- package/dist/tui/commands.d.ts +24 -7
- package/dist/tui/commands.js +200 -86
- package/dist/tui/index.js +8 -8
- package/dist/tui/service-panel.js +3 -4
- package/dist/types.d.ts +2 -0
- package/dist/web-ui/content/scripts.js +250 -53
- package/dist/web-ui/content/styles.css +345 -109
- package/package.json +1 -1
|
@@ -549,6 +549,7 @@ export class StructuredSessionManager {
|
|
|
549
549
|
? prepareSessionWorktree({ cwd: options.cwd, sessionId: id })
|
|
550
550
|
: null;
|
|
551
551
|
const selectedModel = options.model?.trim() || null;
|
|
552
|
+
const initialThinkingEffort = normalizeThinkingEffort(options.thinkingEffort);
|
|
552
553
|
const snapshot = {
|
|
553
554
|
id,
|
|
554
555
|
sessionKind: "structured",
|
|
@@ -585,6 +586,7 @@ export class StructuredSessionManager {
|
|
|
585
586
|
autoApprovePermissions: shouldAutoApproveForMode(options.mode),
|
|
586
587
|
approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
|
|
587
588
|
selectedModel,
|
|
589
|
+
thinkingEffort: initialThinkingEffort,
|
|
588
590
|
};
|
|
589
591
|
this.sessions.set(id, snapshot);
|
|
590
592
|
this.storage.saveSession(snapshot);
|
|
@@ -1043,6 +1045,11 @@ export class StructuredSessionManager {
|
|
|
1043
1045
|
if (modelChoice && modelChoice !== "default") {
|
|
1044
1046
|
args.push("--model", modelChoice);
|
|
1045
1047
|
}
|
|
1048
|
+
// 思考深度 → --reasoning-effort(off → minimal,standard → low,deep → medium,max → high)
|
|
1049
|
+
const reasoningFlag = thinkingEffortToCodexFlag(session.thinkingEffort);
|
|
1050
|
+
if (reasoningFlag) {
|
|
1051
|
+
args.push("--reasoning-effort", reasoningFlag);
|
|
1052
|
+
}
|
|
1046
1053
|
if (session.claudeSessionId) {
|
|
1047
1054
|
args.push("resume", session.claudeSessionId, "-");
|
|
1048
1055
|
}
|
|
@@ -1384,6 +1391,10 @@ export class StructuredSessionManager {
|
|
|
1384
1391
|
// variadic 参数贪婪吞掉(commander 的 <tools...> 会一直吃 positional 直到
|
|
1385
1392
|
// 下一个 flag)。表现为 claude 报 "Input must be provided either through
|
|
1386
1393
|
// stdin or as a prompt argument when using --print"。
|
|
1394
|
+
//
|
|
1395
|
+
// 思考深度通过给 prompt 前置魔法词触发(think / think hard / ultrathink)。
|
|
1396
|
+
// applyThinkingEffortToPrompt 自身已经做了"用户已写过就不重复加"的保护。
|
|
1397
|
+
const effectivePrompt = applyThinkingEffortToPrompt(prompt, session.thinkingEffort);
|
|
1387
1398
|
const spawnedAt = new Date().toISOString();
|
|
1388
1399
|
const child = spawn("claude", args, {
|
|
1389
1400
|
cwd: session.cwd,
|
|
@@ -1396,13 +1407,13 @@ export class StructuredSessionManager {
|
|
|
1396
1407
|
pid: child.pid ?? null,
|
|
1397
1408
|
cwd: session.cwd,
|
|
1398
1409
|
args,
|
|
1399
|
-
prompt:
|
|
1400
|
-
promptLength:
|
|
1410
|
+
prompt: effectivePrompt.slice(0, 2048),
|
|
1411
|
+
promptLength: effectivePrompt.length,
|
|
1401
1412
|
claudeSessionId: session.claudeSessionId,
|
|
1402
1413
|
spawnedAt,
|
|
1403
1414
|
});
|
|
1404
1415
|
this.pendingChildren.set(sessionId, child);
|
|
1405
|
-
child.stdin?.end(
|
|
1416
|
+
child.stdin?.end(effectivePrompt);
|
|
1406
1417
|
const turnState = {
|
|
1407
1418
|
blocks: [],
|
|
1408
1419
|
result: "",
|
|
@@ -1889,6 +1900,12 @@ export class StructuredSessionManager {
|
|
|
1889
1900
|
// SDK 默认会把整个 process.env 透传给 claude 子进程;这里显式按 inheritEnv 配置组装,
|
|
1890
1901
|
// 否则关闭"继承环境变量"开关时 SDK 路径会被静默忽略。
|
|
1891
1902
|
const sdkEnv = buildChildEnv(this.config.inheritEnv !== false);
|
|
1903
|
+
// 思考深度:off → 显式禁用 thinking,其他 → 给一个固定 budget。
|
|
1904
|
+
// SDK 类型用驼峰 budgetTokens(API 层是 budget_tokens,SDK 内部已做转换)。
|
|
1905
|
+
const sdkThinkingBudget = thinkingEffortToSdkBudget(session.thinkingEffort);
|
|
1906
|
+
const sdkThinking = sdkThinkingBudget > 0
|
|
1907
|
+
? { type: "enabled", budgetTokens: sdkThinkingBudget }
|
|
1908
|
+
: { type: "disabled" };
|
|
1892
1909
|
const sdkOptions = {
|
|
1893
1910
|
cwd: session.cwd,
|
|
1894
1911
|
abortController,
|
|
@@ -1897,6 +1914,7 @@ export class StructuredSessionManager {
|
|
|
1897
1914
|
...(permPolicy.permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}),
|
|
1898
1915
|
...(permPolicy.allowedTools ? { allowedTools: permPolicy.allowedTools } : {}),
|
|
1899
1916
|
...(isManaged ? { disallowedTools: ["AskUserQuestion"] } : {}),
|
|
1917
|
+
thinking: sdkThinking,
|
|
1900
1918
|
includePartialMessages: true,
|
|
1901
1919
|
// 把子 agent 的 text/thinking 也转发回来,UI 才能把"被 Task 召唤来的协作者"
|
|
1902
1920
|
// 渲染成独立角色的群聊消息。关掉这个开关时只会收到子 agent 的 tool_use/tool_result,
|
package/dist/tui/attach.js
CHANGED
|
@@ -230,14 +230,13 @@ export function startAttachTui(deps) {
|
|
|
230
230
|
layout.showToast("服务已安装,按 Shift+S 卸载", "warn", 2500);
|
|
231
231
|
return;
|
|
232
232
|
}
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
});
|
|
233
|
+
const isRoot = typeof process.getuid === "function" ? process.getuid() === 0 : false;
|
|
234
|
+
const body = process.platform === "linux"
|
|
235
|
+
? `将写入 /etc/systemd/system/wand.service,systemctl enable --now,开机自启。\n${isRoot ? "当前是 root,可以直接装。" : "⚠ 需要 root,可以 Ctrl+C 退出 TUI 后跑 sudo wand service:install。"}`
|
|
236
|
+
: process.platform === "darwin"
|
|
237
|
+
? `将写入 /Library/LaunchDaemons/com.wand.web.plist,launchctl load,开机自启。\n${isRoot ? "当前是 root,可以直接装。" : "⚠ 需要 root,退出 TUI 跑 sudo wand service:install。"}`
|
|
238
|
+
: "当前平台暂不支持。";
|
|
239
|
+
const ok = await layout.confirm({ title: "注册为系统服务", body });
|
|
241
240
|
if (!ok)
|
|
242
241
|
return;
|
|
243
242
|
const r = await runOffMicrotask(() => installService({ configPath: deps.configPath }));
|
package/dist/tui/commands.d.ts
CHANGED
|
@@ -36,12 +36,29 @@ export declare function checkUpdate(currentVersion: string): UpdateInfo;
|
|
|
36
36
|
export declare function installUpdate(): CommandResult;
|
|
37
37
|
export declare function openInBrowser(url: string): CommandResult;
|
|
38
38
|
export declare function copyToClipboard(text: string): CommandResult;
|
|
39
|
+
/**
|
|
40
|
+
* 服务安装的作用域:
|
|
41
|
+
* - "system" = Linux 写 /etc/systemd/system/wand.service;macOS 写 /Library/LaunchDaemons/
|
|
42
|
+
* 需要 root,开机自启,所有用户可用,不依赖 login session。
|
|
43
|
+
* - "user" = Linux 写 ~/.config/systemd/user/wand.service;macOS 写 ~/Library/LaunchAgents/
|
|
44
|
+
* 不要 root,登出会被回收(除非 loginctl enable-linger)。
|
|
45
|
+
*
|
|
46
|
+
* 默认 system。
|
|
47
|
+
*/
|
|
48
|
+
export type ServiceScope = "system" | "user";
|
|
49
|
+
export declare const DEFAULT_SERVICE_SCOPE: ServiceScope;
|
|
39
50
|
export interface ServiceContext {
|
|
40
51
|
configPath: string;
|
|
41
52
|
/** wand 可执行文件路径。优先使用 process.argv[1],回退到 which wand。 */
|
|
42
53
|
wandBin?: string;
|
|
54
|
+
/** 显式指定作用域。不传走 DEFAULT_SERVICE_SCOPE。 */
|
|
55
|
+
scope?: ServiceScope;
|
|
56
|
+
}
|
|
57
|
+
export interface ServiceOpts {
|
|
58
|
+
/** 不传 = 自动检测已装的那个;都没装就用 default。 */
|
|
59
|
+
scope?: ServiceScope;
|
|
43
60
|
}
|
|
44
|
-
export declare function isServiceInstalled(): boolean;
|
|
61
|
+
export declare function isServiceInstalled(scope?: ServiceScope): boolean;
|
|
45
62
|
export interface ServiceStatus {
|
|
46
63
|
/** 是否已安装服务文件。 */
|
|
47
64
|
installed: boolean;
|
|
@@ -54,11 +71,11 @@ export interface ServiceStatus {
|
|
|
54
71
|
/** 平台。 */
|
|
55
72
|
platform: NodeJS.Platform;
|
|
56
73
|
}
|
|
57
|
-
export declare function serviceStatus(): ServiceStatus;
|
|
58
|
-
export declare function serviceStart(): CommandResult;
|
|
59
|
-
export declare function serviceStop(): CommandResult;
|
|
60
|
-
export declare function serviceRestart(): CommandResult;
|
|
74
|
+
export declare function serviceStatus(opts?: ServiceOpts): ServiceStatus;
|
|
75
|
+
export declare function serviceStart(opts?: ServiceOpts): CommandResult;
|
|
76
|
+
export declare function serviceStop(opts?: ServiceOpts): CommandResult;
|
|
77
|
+
export declare function serviceRestart(opts?: ServiceOpts): CommandResult;
|
|
61
78
|
/** 取最近 N 行服务日志。 */
|
|
62
|
-
export declare function serviceLogs(lines?: number): CommandResult;
|
|
79
|
+
export declare function serviceLogs(lines?: number, opts?: ServiceOpts): CommandResult;
|
|
63
80
|
export declare function installService(ctx: ServiceContext): CommandResult;
|
|
64
|
-
export declare function uninstallService(): CommandResult;
|
|
81
|
+
export declare function uninstallService(opts?: ServiceOpts): CommandResult;
|
package/dist/tui/commands.js
CHANGED
|
@@ -122,14 +122,44 @@ function clipboardCandidates() {
|
|
|
122
122
|
{ cmd: "xsel", args: ["--clipboard", "--input"] },
|
|
123
123
|
];
|
|
124
124
|
}
|
|
125
|
-
export
|
|
126
|
-
|
|
125
|
+
export const DEFAULT_SERVICE_SCOPE = "system";
|
|
126
|
+
/** 当前 process 是不是 root(POSIX)。Windows 永远返回 false。 */
|
|
127
|
+
function isRoot() {
|
|
128
|
+
const fn = process.getuid;
|
|
129
|
+
if (typeof fn !== "function")
|
|
130
|
+
return false;
|
|
131
|
+
try {
|
|
132
|
+
return fn.call(process) === 0;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** 自动检测哪个 scope 已经装了 unit;优先 system,找不到就 user。两个都没装返回 null。 */
|
|
139
|
+
function detectInstalledScope() {
|
|
140
|
+
if (existsSync(servicePathFor("system")))
|
|
141
|
+
return "system";
|
|
142
|
+
if (existsSync(servicePathFor("user")))
|
|
143
|
+
return "user";
|
|
144
|
+
return null;
|
|
127
145
|
}
|
|
128
|
-
|
|
146
|
+
/** 给定一个 opts.scope,如果没传就按 detect → default 顺序回退。 */
|
|
147
|
+
function resolveScope(opts) {
|
|
148
|
+
if (opts?.scope)
|
|
149
|
+
return opts.scope;
|
|
150
|
+
return detectInstalledScope() ?? DEFAULT_SERVICE_SCOPE;
|
|
151
|
+
}
|
|
152
|
+
export function isServiceInstalled(scope) {
|
|
153
|
+
if (scope)
|
|
154
|
+
return existsSync(servicePathFor(scope));
|
|
155
|
+
return detectInstalledScope() !== null;
|
|
156
|
+
}
|
|
157
|
+
export function serviceStatus(opts) {
|
|
158
|
+
const scope = resolveScope(opts);
|
|
129
159
|
if (process.platform === "linux")
|
|
130
|
-
return systemdStatus();
|
|
160
|
+
return systemdStatus(scope);
|
|
131
161
|
if (process.platform === "darwin")
|
|
132
|
-
return launchdStatus();
|
|
162
|
+
return launchdStatus(scope);
|
|
133
163
|
return {
|
|
134
164
|
installed: false,
|
|
135
165
|
state: "unsupported",
|
|
@@ -138,31 +168,38 @@ export function serviceStatus() {
|
|
|
138
168
|
platform: process.platform,
|
|
139
169
|
};
|
|
140
170
|
}
|
|
141
|
-
export function serviceStart() {
|
|
171
|
+
export function serviceStart(opts) {
|
|
172
|
+
const scope = resolveScope(opts);
|
|
142
173
|
if (process.platform === "linux")
|
|
143
|
-
return runSystemctl(["start", "wand.service"], "已启动");
|
|
174
|
+
return runSystemctl(scope, ["start", "wand.service"], "已启动");
|
|
144
175
|
if (process.platform === "darwin")
|
|
145
|
-
return launchctlLoad();
|
|
176
|
+
return launchctlLoad(scope);
|
|
146
177
|
return unsupported();
|
|
147
178
|
}
|
|
148
|
-
export function serviceStop() {
|
|
179
|
+
export function serviceStop(opts) {
|
|
180
|
+
const scope = resolveScope(opts);
|
|
149
181
|
if (process.platform === "linux")
|
|
150
|
-
return runSystemctl(["stop", "wand.service"], "已停止");
|
|
182
|
+
return runSystemctl(scope, ["stop", "wand.service"], "已停止");
|
|
151
183
|
if (process.platform === "darwin")
|
|
152
|
-
return launchctlUnload();
|
|
184
|
+
return launchctlUnload(scope);
|
|
153
185
|
return unsupported();
|
|
154
186
|
}
|
|
155
|
-
export function serviceRestart() {
|
|
187
|
+
export function serviceRestart(opts) {
|
|
188
|
+
const scope = resolveScope(opts);
|
|
156
189
|
if (process.platform === "linux")
|
|
157
|
-
return runSystemctl(["restart", "wand.service"], "已重启");
|
|
190
|
+
return runSystemctl(scope, ["restart", "wand.service"], "已重启");
|
|
158
191
|
if (process.platform === "darwin")
|
|
159
|
-
return launchdRestart();
|
|
192
|
+
return launchdRestart(scope);
|
|
160
193
|
return unsupported();
|
|
161
194
|
}
|
|
162
195
|
/** 取最近 N 行服务日志。 */
|
|
163
|
-
export function serviceLogs(lines = 80) {
|
|
196
|
+
export function serviceLogs(lines = 80, opts) {
|
|
197
|
+
const scope = resolveScope(opts);
|
|
164
198
|
if (process.platform === "linux") {
|
|
165
|
-
const
|
|
199
|
+
const args = scope === "user"
|
|
200
|
+
? ["--user", "-u", "wand.service", "-n", String(lines), "--no-pager"]
|
|
201
|
+
: ["-u", "wand.service", "-n", String(lines), "--no-pager"];
|
|
202
|
+
const r = spawnSync("journalctl", args, { encoding: "utf8", timeout: 10_000 });
|
|
166
203
|
if (r.status === 0)
|
|
167
204
|
return { ok: true, message: `journalctl 输出 ${lines} 行`, detail: r.stdout.trim() };
|
|
168
205
|
return { ok: false, message: "journalctl 调用失败", detail: r.stderr.trim() || `exit ${r.status}` };
|
|
@@ -175,22 +212,27 @@ export function serviceLogs(lines = 80) {
|
|
|
175
212
|
}
|
|
176
213
|
return unsupported();
|
|
177
214
|
}
|
|
178
|
-
|
|
179
|
-
|
|
215
|
+
/** systemctl 调用根据 scope 决定要不要 --user。system scope 也意味着调用方需要 root。 */
|
|
216
|
+
function systemctlBaseArgs(scope) {
|
|
217
|
+
return scope === "user" ? ["--user"] : [];
|
|
218
|
+
}
|
|
219
|
+
function systemdStatus(scope) {
|
|
220
|
+
const installed = isServiceInstalled(scope);
|
|
180
221
|
if (!installed) {
|
|
181
222
|
return {
|
|
182
223
|
installed: false,
|
|
183
224
|
state: "unknown",
|
|
184
|
-
description:
|
|
225
|
+
description: `未安装 (${scope} scope)`,
|
|
185
226
|
raw: "",
|
|
186
227
|
platform: "linux",
|
|
187
228
|
};
|
|
188
229
|
}
|
|
189
|
-
|
|
190
|
-
|
|
230
|
+
const base = systemctlBaseArgs(scope);
|
|
231
|
+
// 用 `is-active` + `show -p ...` 拿结构化数据
|
|
232
|
+
const active = spawnSync("systemctl", [...base, "is-active", "wand.service"], { encoding: "utf8" });
|
|
191
233
|
const stateRaw = (active.stdout || "").trim();
|
|
192
234
|
const show = spawnSync("systemctl", [
|
|
193
|
-
|
|
235
|
+
...base,
|
|
194
236
|
"show",
|
|
195
237
|
"wand.service",
|
|
196
238
|
"-p",
|
|
@@ -203,13 +245,13 @@ function systemdStatus() {
|
|
|
203
245
|
"MainPID",
|
|
204
246
|
], { encoding: "utf8" });
|
|
205
247
|
const props = parseSystemctlShow(show.stdout || "");
|
|
206
|
-
const status = spawnSync("systemctl", [
|
|
248
|
+
const status = spawnSync("systemctl", [...base, "status", "wand.service", "--no-pager", "-n", "5"], {
|
|
207
249
|
encoding: "utf8",
|
|
208
250
|
});
|
|
209
251
|
const sub = props.SubState || stateRaw;
|
|
210
252
|
const since = props.ActiveEnterTimestamp ? ` since ${props.ActiveEnterTimestamp}` : "";
|
|
211
253
|
const pid = props.MainPID && props.MainPID !== "0" ? ` · PID ${props.MainPID}` : "";
|
|
212
|
-
const desc =
|
|
254
|
+
const desc = `[${scope}] ${stateRaw}${sub ? ` (${sub})` : ""}${since}${pid}`;
|
|
213
255
|
let normalized = "unknown";
|
|
214
256
|
if (stateRaw === "active")
|
|
215
257
|
normalized = "active";
|
|
@@ -227,13 +269,13 @@ function systemdStatus() {
|
|
|
227
269
|
platform: "linux",
|
|
228
270
|
};
|
|
229
271
|
}
|
|
230
|
-
function launchdStatus() {
|
|
231
|
-
const installed = isServiceInstalled();
|
|
272
|
+
function launchdStatus(scope) {
|
|
273
|
+
const installed = isServiceInstalled(scope);
|
|
232
274
|
if (!installed) {
|
|
233
275
|
return {
|
|
234
276
|
installed: false,
|
|
235
277
|
state: "unknown",
|
|
236
|
-
description:
|
|
278
|
+
description: `未安装 (${scope} scope)`,
|
|
237
279
|
raw: "",
|
|
238
280
|
platform: "darwin",
|
|
239
281
|
};
|
|
@@ -244,18 +286,17 @@ function launchdStatus() {
|
|
|
244
286
|
return {
|
|
245
287
|
installed: true,
|
|
246
288
|
state: "inactive",
|
|
247
|
-
description:
|
|
289
|
+
description: `[${scope}] loaded 但未在运行(launchctl list 找不到)`,
|
|
248
290
|
raw: list.stderr || "",
|
|
249
291
|
platform: "darwin",
|
|
250
292
|
};
|
|
251
293
|
}
|
|
252
|
-
// launchctl list <label> 给出多行 plist 格式:包含 PID / LastExitStatus
|
|
253
294
|
const text = list.stdout;
|
|
254
295
|
const pidMatch = text.match(/"PID"\s*=\s*(\d+);/);
|
|
255
296
|
const exitMatch = text.match(/"LastExitStatus"\s*=\s*(-?\d+);/);
|
|
256
297
|
const pid = pidMatch ? Number(pidMatch[1]) : 0;
|
|
257
298
|
const lastExit = exitMatch ? Number(exitMatch[1]) : 0;
|
|
258
|
-
const desc = pid > 0 ? `running · PID ${pid}` : `stopped (last exit=${lastExit})`;
|
|
299
|
+
const desc = pid > 0 ? `[${scope}] running · PID ${pid}` : `[${scope}] stopped (last exit=${lastExit})`;
|
|
259
300
|
return {
|
|
260
301
|
installed: true,
|
|
261
302
|
state: pid > 0 ? "active" : "inactive",
|
|
@@ -264,48 +305,55 @@ function launchdStatus() {
|
|
|
264
305
|
platform: "darwin",
|
|
265
306
|
};
|
|
266
307
|
}
|
|
267
|
-
function runSystemctl(args, successWord) {
|
|
268
|
-
const
|
|
308
|
+
function runSystemctl(scope, args, successWord) {
|
|
309
|
+
const base = systemctlBaseArgs(scope);
|
|
310
|
+
const r = spawnSync("systemctl", [...base, ...args], { encoding: "utf8", timeout: 15_000 });
|
|
311
|
+
const scopeLabel = scope === "user" ? "--user " : "";
|
|
269
312
|
if (r.status === 0) {
|
|
270
|
-
return { ok: true, message: `systemctl
|
|
313
|
+
return { ok: true, message: `systemctl ${scopeLabel}${args.join(" ")} ${successWord}` };
|
|
271
314
|
}
|
|
315
|
+
// system scope 没拿到 root 是最常见错误,给一个明确提示
|
|
316
|
+
const stderr = (r.stderr || "").trim();
|
|
317
|
+
const hint = scope === "system" && !isRoot()
|
|
318
|
+
? "\n提示: 系统级服务操作需要 root,请用 sudo 重试。"
|
|
319
|
+
: "";
|
|
272
320
|
return {
|
|
273
321
|
ok: false,
|
|
274
|
-
message: `systemctl 失败 (exit ${r.status})`,
|
|
275
|
-
detail: ((r.stdout || "") + "\n" +
|
|
322
|
+
message: `systemctl ${scopeLabel}${args.join(" ")} 失败 (exit ${r.status})`,
|
|
323
|
+
detail: ((r.stdout || "") + "\n" + stderr + hint).trim(),
|
|
276
324
|
};
|
|
277
325
|
}
|
|
278
|
-
function launchctlLoad() {
|
|
279
|
-
const plist =
|
|
326
|
+
function launchctlLoad(scope) {
|
|
327
|
+
const plist = servicePathFor(scope);
|
|
280
328
|
if (!existsSync(plist))
|
|
281
|
-
return { ok: false, message:
|
|
329
|
+
return { ok: false, message: `未安装 (${plist} 不存在)` };
|
|
282
330
|
const r = spawnSync("launchctl", ["load", "-w", plist], { encoding: "utf8", timeout: 10_000 });
|
|
283
331
|
if (r.status === 0)
|
|
284
|
-
return { ok: true, message:
|
|
332
|
+
return { ok: true, message: `已 launchctl load (${scope})` };
|
|
285
333
|
return {
|
|
286
334
|
ok: false,
|
|
287
335
|
message: `launchctl load 失败 (exit ${r.status})`,
|
|
288
336
|
detail: ((r.stdout || "") + "\n" + (r.stderr || "")).trim(),
|
|
289
337
|
};
|
|
290
338
|
}
|
|
291
|
-
function launchctlUnload() {
|
|
292
|
-
const plist =
|
|
339
|
+
function launchctlUnload(scope) {
|
|
340
|
+
const plist = servicePathFor(scope);
|
|
293
341
|
if (!existsSync(plist))
|
|
294
|
-
return { ok: false, message:
|
|
342
|
+
return { ok: false, message: `未安装 (${plist} 不存在)` };
|
|
295
343
|
const r = spawnSync("launchctl", ["unload", plist], { encoding: "utf8", timeout: 10_000 });
|
|
296
344
|
if (r.status === 0)
|
|
297
|
-
return { ok: true, message:
|
|
345
|
+
return { ok: true, message: `已 launchctl unload (${scope})` };
|
|
298
346
|
return {
|
|
299
347
|
ok: false,
|
|
300
348
|
message: `launchctl unload 失败 (exit ${r.status})`,
|
|
301
349
|
detail: ((r.stdout || "") + "\n" + (r.stderr || "")).trim(),
|
|
302
350
|
};
|
|
303
351
|
}
|
|
304
|
-
function launchdRestart() {
|
|
305
|
-
const stop = launchctlUnload();
|
|
306
|
-
const start = launchctlLoad();
|
|
352
|
+
function launchdRestart(scope) {
|
|
353
|
+
const stop = launchctlUnload(scope);
|
|
354
|
+
const start = launchctlLoad(scope);
|
|
307
355
|
if (stop.ok && start.ok)
|
|
308
|
-
return { ok: true, message:
|
|
356
|
+
return { ok: true, message: `已 launchd 重启 (${scope})` };
|
|
309
357
|
return {
|
|
310
358
|
ok: false,
|
|
311
359
|
message: "launchd 重启失败",
|
|
@@ -326,25 +374,46 @@ function parseSystemctlShow(text) {
|
|
|
326
374
|
return out;
|
|
327
375
|
}
|
|
328
376
|
export function installService(ctx) {
|
|
377
|
+
const scope = ctx.scope ?? DEFAULT_SERVICE_SCOPE;
|
|
378
|
+
// system scope 需要 root(除了 Windows,那里两个都不支持)
|
|
379
|
+
if (scope === "system" && !isRoot() && process.platform !== "win32") {
|
|
380
|
+
return {
|
|
381
|
+
ok: false,
|
|
382
|
+
message: "系统级服务安装需要 root 权限",
|
|
383
|
+
detail: "请用 sudo 重跑,或传 --user 走用户级安装(不需要 root)。",
|
|
384
|
+
};
|
|
385
|
+
}
|
|
329
386
|
if (process.platform === "linux")
|
|
330
|
-
return
|
|
387
|
+
return installSystemdService(ctx, scope);
|
|
331
388
|
if (process.platform === "darwin")
|
|
332
|
-
return
|
|
389
|
+
return installLaunchdService(ctx, scope);
|
|
333
390
|
return { ok: false, message: `当前平台 ${process.platform} 暂不支持服务注册` };
|
|
334
391
|
}
|
|
335
|
-
export function uninstallService() {
|
|
392
|
+
export function uninstallService(opts) {
|
|
393
|
+
const scope = resolveScope(opts);
|
|
394
|
+
if (scope === "system" && !isRoot() && process.platform !== "win32") {
|
|
395
|
+
return {
|
|
396
|
+
ok: false,
|
|
397
|
+
message: "系统级服务卸载需要 root 权限",
|
|
398
|
+
detail: "请用 sudo 重跑。",
|
|
399
|
+
};
|
|
400
|
+
}
|
|
336
401
|
if (process.platform === "linux")
|
|
337
|
-
return
|
|
402
|
+
return uninstallSystemdService(scope);
|
|
338
403
|
if (process.platform === "darwin")
|
|
339
|
-
return
|
|
404
|
+
return uninstallLaunchdService(scope);
|
|
340
405
|
return { ok: false, message: `当前平台 ${process.platform} 暂不支持服务注册` };
|
|
341
406
|
}
|
|
342
|
-
function
|
|
407
|
+
function servicePathFor(scope) {
|
|
343
408
|
if (process.platform === "linux") {
|
|
344
|
-
return
|
|
409
|
+
return scope === "user"
|
|
410
|
+
? path.join(os.homedir(), ".config/systemd/user/wand.service")
|
|
411
|
+
: "/etc/systemd/system/wand.service";
|
|
345
412
|
}
|
|
346
413
|
if (process.platform === "darwin") {
|
|
347
|
-
return
|
|
414
|
+
return scope === "user"
|
|
415
|
+
? path.join(os.homedir(), "Library/LaunchAgents/com.wand.web.plist")
|
|
416
|
+
: "/Library/LaunchDaemons/com.wand.web.plist";
|
|
348
417
|
}
|
|
349
418
|
return "";
|
|
350
419
|
}
|
|
@@ -359,18 +428,26 @@ function resolveWandBin(ctx) {
|
|
|
359
428
|
return which.stdout.trim();
|
|
360
429
|
return "wand";
|
|
361
430
|
}
|
|
362
|
-
|
|
363
|
-
|
|
431
|
+
/** 当前 process 的真实用户名(system unit 里要写 User=)。 */
|
|
432
|
+
function currentUserName() {
|
|
433
|
+
try {
|
|
434
|
+
return os.userInfo().username || "root";
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
return process.env.USER || process.env.LOGNAME || "root";
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function installSystemdService(ctx, scope) {
|
|
441
|
+
const unitPath = servicePathFor(scope);
|
|
364
442
|
const wandBin = resolveWandBin(ctx);
|
|
365
443
|
const nodeBin = process.execPath;
|
|
366
|
-
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
// 新进程,把更新真正生效。
|
|
370
|
-
const unit = [
|
|
444
|
+
const nodeBinDir = path.dirname(nodeBin);
|
|
445
|
+
// 共同字段
|
|
446
|
+
const commonExec = [
|
|
371
447
|
"[Unit]",
|
|
372
448
|
"Description=wand web console",
|
|
373
|
-
"After=network.target",
|
|
449
|
+
"After=network-online.target",
|
|
450
|
+
"Wants=network-online.target",
|
|
374
451
|
"",
|
|
375
452
|
"[Service]",
|
|
376
453
|
"Type=simple",
|
|
@@ -378,11 +455,40 @@ function installSystemdUserService(ctx) {
|
|
|
378
455
|
`Environment=WAND_NO_TUI=1`,
|
|
379
456
|
"Restart=always",
|
|
380
457
|
"RestartSec=3",
|
|
381
|
-
"",
|
|
382
|
-
"
|
|
383
|
-
"
|
|
384
|
-
|
|
385
|
-
|
|
458
|
+
"StandardOutput=journal",
|
|
459
|
+
"StandardError=journal",
|
|
460
|
+
"SyslogIdentifier=wand",
|
|
461
|
+
];
|
|
462
|
+
// system scope 额外要:User= / HOME= / PATH=(systemd 系统级 service 默认 HOME=/root 是不行的,
|
|
463
|
+
// 而且 PATH 极简,nvm 装的 node spawn 出来的 npm 子进程找不到)
|
|
464
|
+
let unitLines;
|
|
465
|
+
if (scope === "system") {
|
|
466
|
+
const runUser = currentUserName();
|
|
467
|
+
const runHome = process.env.HOME || os.homedir();
|
|
468
|
+
unitLines = [
|
|
469
|
+
...commonExec,
|
|
470
|
+
`User=${runUser}`,
|
|
471
|
+
`WorkingDirectory=${runHome}`,
|
|
472
|
+
`Environment=HOME=${runHome}`,
|
|
473
|
+
`Environment=PATH=${nodeBinDir}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
|
|
474
|
+
"OOMScoreAdjust=-500",
|
|
475
|
+
"",
|
|
476
|
+
"[Install]",
|
|
477
|
+
"WantedBy=multi-user.target",
|
|
478
|
+
"",
|
|
479
|
+
];
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
// user scope: 跑在 user@<uid>.service cgroup 内,HOME/PATH 自带
|
|
483
|
+
unitLines = [
|
|
484
|
+
...commonExec,
|
|
485
|
+
"",
|
|
486
|
+
"[Install]",
|
|
487
|
+
"WantedBy=default.target",
|
|
488
|
+
"",
|
|
489
|
+
];
|
|
490
|
+
}
|
|
491
|
+
const unit = unitLines.join("\n");
|
|
386
492
|
try {
|
|
387
493
|
mkdirSync(path.dirname(unitPath), { recursive: true });
|
|
388
494
|
writeFileSync(unitPath, unit, "utf8");
|
|
@@ -390,51 +496,59 @@ function installSystemdUserService(ctx) {
|
|
|
390
496
|
catch (err) {
|
|
391
497
|
return { ok: false, message: `写入 unit 失败: ${errMsg(err)}` };
|
|
392
498
|
}
|
|
393
|
-
const
|
|
394
|
-
const
|
|
499
|
+
const base = systemctlBaseArgs(scope);
|
|
500
|
+
const reload = spawnSync("systemctl", [...base, "daemon-reload"], { encoding: "utf8" });
|
|
501
|
+
const enable = spawnSync("systemctl", [...base, "enable", "--now", "wand.service"], { encoding: "utf8" });
|
|
502
|
+
const hints = scope === "user"
|
|
503
|
+
? "提示: 若需登出后保持运行,请运行 `loginctl enable-linger $USER`"
|
|
504
|
+
: "提示: 已写入系统级 unit;开机自启已 enable。";
|
|
395
505
|
const detail = [
|
|
506
|
+
`scope: ${scope}`,
|
|
396
507
|
`unit: ${unitPath}`,
|
|
397
508
|
`daemon-reload: ${reload.status === 0 ? "ok" : `failed (${reload.stderr.trim()})`}`,
|
|
398
509
|
`enable --now: ${enable.status === 0 ? "ok" : `failed (${enable.stderr.trim()})`}`,
|
|
399
510
|
"",
|
|
400
|
-
|
|
511
|
+
hints,
|
|
401
512
|
].join("\n");
|
|
402
513
|
if (enable.status !== 0) {
|
|
403
514
|
return {
|
|
404
515
|
ok: false,
|
|
405
|
-
message:
|
|
516
|
+
message: `已写入 unit,但 systemctl ${scope === "user" ? "--user " : ""}启用失败`,
|
|
406
517
|
detail,
|
|
407
518
|
};
|
|
408
519
|
}
|
|
409
520
|
return {
|
|
410
521
|
ok: true,
|
|
411
|
-
message: `已注册 systemd
|
|
522
|
+
message: `已注册 systemd ${scope === "user" ? "用户" : "系统"}服务: ${unitPath}`,
|
|
412
523
|
detail,
|
|
413
524
|
};
|
|
414
525
|
}
|
|
415
|
-
function
|
|
416
|
-
const unitPath =
|
|
526
|
+
function uninstallSystemdService(scope) {
|
|
527
|
+
const unitPath = servicePathFor(scope);
|
|
417
528
|
if (!existsSync(unitPath)) {
|
|
418
|
-
return { ok: false, message:
|
|
529
|
+
return { ok: false, message: `未检测到已安装的 systemd ${scope} 服务` };
|
|
419
530
|
}
|
|
420
|
-
const
|
|
531
|
+
const base = systemctlBaseArgs(scope);
|
|
532
|
+
const stop = spawnSync("systemctl", [...base, "disable", "--now", "wand.service"], { encoding: "utf8" });
|
|
421
533
|
try {
|
|
422
534
|
unlinkSync(unitPath);
|
|
423
535
|
}
|
|
424
536
|
catch (err) {
|
|
425
537
|
return { ok: false, message: `删除 unit 失败: ${errMsg(err)}` };
|
|
426
538
|
}
|
|
427
|
-
spawnSync("systemctl", [
|
|
539
|
+
spawnSync("systemctl", [...base, "daemon-reload"], { encoding: "utf8" });
|
|
428
540
|
return {
|
|
429
541
|
ok: true,
|
|
430
|
-
message:
|
|
542
|
+
message: `已卸载 systemd ${scope === "user" ? "用户" : "系统"}服务`,
|
|
431
543
|
detail: stop.status === 0 ? "disable --now: ok" : `disable --now: ${stop.stderr.trim()}`,
|
|
432
544
|
};
|
|
433
545
|
}
|
|
434
|
-
function
|
|
435
|
-
const plistPath =
|
|
546
|
+
function installLaunchdService(ctx, scope) {
|
|
547
|
+
const plistPath = servicePathFor(scope);
|
|
436
548
|
const wandBin = resolveWandBin(ctx);
|
|
437
549
|
const nodeBin = process.execPath;
|
|
550
|
+
// LaunchDaemon (system) 跑在 root,但 wand 数据应该归 ctx.configPath 的 owner;
|
|
551
|
+
// 简化处理:system 模式下不强制改 owner,让用户自己提前 chown。
|
|
438
552
|
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
439
553
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
440
554
|
<plist version="1.0">
|
|
@@ -474,13 +588,13 @@ function installLaunchdAgent(ctx) {
|
|
|
474
588
|
}
|
|
475
589
|
return {
|
|
476
590
|
ok: true,
|
|
477
|
-
message: `已注册 launchd
|
|
591
|
+
message: `已注册 launchd ${scope === "user" ? "用户代理" : "系统守护"}: ${plistPath}`,
|
|
478
592
|
};
|
|
479
593
|
}
|
|
480
|
-
function
|
|
481
|
-
const plistPath =
|
|
594
|
+
function uninstallLaunchdService(scope) {
|
|
595
|
+
const plistPath = servicePathFor(scope);
|
|
482
596
|
if (!existsSync(plistPath)) {
|
|
483
|
-
return { ok: false, message:
|
|
597
|
+
return { ok: false, message: `未检测到已安装的 launchd ${scope} 服务` };
|
|
484
598
|
}
|
|
485
599
|
const unload = spawnSync("launchctl", ["unload", "-w", plistPath], { encoding: "utf8" });
|
|
486
600
|
try {
|
|
@@ -491,7 +605,7 @@ function uninstallLaunchdAgent() {
|
|
|
491
605
|
}
|
|
492
606
|
return {
|
|
493
607
|
ok: true,
|
|
494
|
-
message:
|
|
608
|
+
message: `已卸载 launchd ${scope === "user" ? "用户代理" : "系统守护"}`,
|
|
495
609
|
detail: unload.status === 0 ? "unload: ok" : `unload: ${unload.stderr.trim()}`,
|
|
496
610
|
};
|
|
497
611
|
}
|