@co0ontty/wand 2.9.1 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -25,7 +25,7 @@ import { checkPackageUpdateAsync, installPackageGloballyAsync, normalizeUpdateCh
25
25
  import { repairServiceUnitAfterUpdate } from "./service-self-repair.js";
26
26
  import { computeRelaunch } from "./relaunch.js";
27
27
  import { isServiceInstalled } from "./tui/commands.js";
28
- import { canUseDetachedUpdateHelper, startDetachedUpdateHelper } from "./update-helper.js";
28
+ import { canUseDetachedUpdateHelper, checkManagedServiceUpdatePreflight, startDetachedUpdateHelper, } from "./update-helper.js";
29
29
  import { registerUploadRoutes } from "./upload-routes.js";
30
30
  import { optimizePrompt, PromptOptimizeError } from "./prompt-optimizer.js";
31
31
  import { resolveDatabasePath, WandStorage } from "./storage.js";
@@ -1884,9 +1884,6 @@ export async function startServer(config, configPath) {
1884
1884
  version: targetLabel,
1885
1885
  logPath: helper.logPath,
1886
1886
  });
1887
- setTimeout(() => {
1888
- shutdownForDetachedUpdate();
1889
- }, 500);
1890
1887
  }
1891
1888
  catch (error) {
1892
1889
  res.status(500).json({ error: getErrorMessage(error, "更新失败。") });
@@ -2467,18 +2464,6 @@ export async function startServer(config, configPath) {
2467
2464
  // Force exit after 5s if graceful shutdown stalls
2468
2465
  setTimeout(() => process.exit(0), 5000);
2469
2466
  }
2470
- function shutdownForDetachedUpdate() {
2471
- try {
2472
- wss.clients.forEach((client) => client.close());
2473
- }
2474
- catch {
2475
- /* noop */
2476
- }
2477
- server.close(() => {
2478
- process.exit(0);
2479
- });
2480
- setTimeout(() => process.exit(0), 3000);
2481
- }
2482
2467
  app.post("/api/restart", async (_req, res) => {
2483
2468
  res.json({ ok: true, message: "服务正在重启..." });
2484
2469
  wsManager.emitEvent({
@@ -2607,6 +2592,21 @@ export async function startServer(config, configPath) {
2607
2592
  });
2608
2593
  return;
2609
2594
  }
2595
+ const servicePreflight = checkManagedServiceUpdatePreflight();
2596
+ if (!servicePreflight.ok) {
2597
+ process.stdout.write(`[wand] 自动更新已取消: ${servicePreflight.message}\n`);
2598
+ wsManager.emitEvent({
2599
+ type: "notification",
2600
+ sessionId: "__system__",
2601
+ data: {
2602
+ kind: "auto-update-failed",
2603
+ current: info.current,
2604
+ latest: info.latest,
2605
+ error: servicePreflight.message,
2606
+ },
2607
+ });
2608
+ return;
2609
+ }
2610
2610
  // Auto-update: install and restart
2611
2611
  process.stdout.write(`[wand] 自动更新:正在从 ${info.current} 更新到 ${info.latest}...\n`);
2612
2612
  wsManager.emitEvent({
@@ -10,7 +10,7 @@ import { type ServiceScope } from "./tui/commands.js";
10
10
  *
11
11
  * 这里在装包成功后调用,best-effort:
12
12
  * - 没装服务 → 跳过;
13
- * - 装了服务 → 用 preferGlobalBin 重写 unit(ExecStart→全局 dist/cli.js
13
+ * - 装了服务 → 用 preferGlobalBin 重写 unit(ExecStart→全局稳定 wand shim
14
14
  * Environment=PATH 取当前已被 path-repair 修复过的 process.env.PATH)+ daemon-reload;
15
15
  * - system scope 非 root 无法写 /etc → installService 返回失败,这里捕获成 warning,
16
16
  * 绝不抛错中断更新流程。
@@ -22,7 +22,7 @@ export function repairServiceUnitAfterUpdate(configPath) {
22
22
  return {
23
23
  repaired: true,
24
24
  scope,
25
- message: `已用全局安装重写 ${scope} 服务 unit(刷新 ExecStart / PATH)`,
25
+ message: `已用全局稳定入口重写 ${scope} 服务 unit(刷新 ExecStart / PATH)`,
26
26
  };
27
27
  }
28
28
  const tail = scope === "system"
@@ -58,8 +58,8 @@ export interface ServiceContext {
58
58
  /** 显式指定作用域。不传走 DEFAULT_SERVICE_SCOPE。 */
59
59
  scope?: ServiceScope;
60
60
  /**
61
- * 更新后自修复场景:优先把 ExecStart 钉到「全局安装的 wand」(npm root -g 下的
62
- * dist/cli.js),而不是 process.argv[1]。源码安装的用户更新到 npm/GitHub 版后,
61
+ * 更新后自修复场景:优先把 ExecStart 钉到 npm 全局 wand shim,而不是
62
+ * process.argv[1]。源码安装的用户更新到 npm/GitHub 版后,
63
63
  * argv[1] 仍是旧源码路径,沿用它会让重启跑回旧二进制。
64
64
  */
65
65
  preferGlobalBin?: boolean;
@@ -10,7 +10,7 @@ import os from "node:os";
10
10
  import path from "node:path";
11
11
  import process from "node:process";
12
12
  import { fileURLToPath } from "node:url";
13
- import { checkPackageUpdateSync, getInstallSpecForChannel, installPackageGloballySync, normalizeUpdateChannel, resolveGlobalWandCli, } from "../npm-update-utils.js";
13
+ import { checkPackageUpdateSync, getInstallSpecForChannel, installPackageGloballySync, normalizeUpdateChannel, resolveGlobalWandBin, resolveGlobalWandCli, } from "../npm-update-utils.js";
14
14
  import { whichSync } from "../path-repair.js";
15
15
  import { computeRelaunch } from "../relaunch.js";
16
16
  import { ensureDatabaseFile, resolveDatabasePath, WandStorage } from "../storage.js";
@@ -95,6 +95,9 @@ export function checkUpdate(currentVersion, channel = "stable") {
95
95
  * 返回 npm 输出供调试。
96
96
  */
97
97
  export function installUpdate(channel = "stable") {
98
+ const serviceSafety = checkInstalledServiceEntrypointsForUpdate();
99
+ if (!serviceSafety.ok)
100
+ return serviceSafety;
98
101
  const res = installPackageGloballySync(getInstallSpecForChannel(channel), 180_000);
99
102
  const out = (res.stdout || "") + (res.stderr ? "\n" + res.stderr : "");
100
103
  const trail = res.attempts.length > 1
@@ -537,12 +540,56 @@ function servicePathFor(scope) {
537
540
  }
538
541
  return "";
539
542
  }
543
+ function readServiceEntrypointForUpdate(scope) {
544
+ const servicePath = servicePathFor(scope);
545
+ if (process.platform === "darwin") {
546
+ const result = spawnSync("plutil", ["-extract", "ProgramArguments.1", "raw", "-o", "-", servicePath], { encoding: "utf8", timeout: 5_000 });
547
+ return result.status === 0 ? (result.stdout || "").trim() || null : null;
548
+ }
549
+ if (process.platform === "linux") {
550
+ try {
551
+ const execStart = readFileSync(servicePath, "utf8").split("\n").find((line) => line.startsWith("ExecStart="));
552
+ if (!execStart)
553
+ return null;
554
+ return execStart.slice("ExecStart=".length).trim().split(/\s+/)[1] || null;
555
+ }
556
+ catch {
557
+ return null;
558
+ }
559
+ }
560
+ return null;
561
+ }
562
+ function checkInstalledServiceEntrypointsForUpdate() {
563
+ const scopes = ["system", "user"].filter((scope) => isServiceInstalled(scope));
564
+ if (scopes.length === 0)
565
+ return { ok: true, message: "未安装服务" };
566
+ const stableBin = resolveGlobalWandBin();
567
+ if (!stableBin) {
568
+ return { ok: false, message: "npm 全局 wand shim 不存在,已取消更新以保护当前服务。" };
569
+ }
570
+ for (const scope of scopes) {
571
+ const entrypoint = readServiceEntrypointForUpdate(scope);
572
+ if (!entrypoint || path.resolve(entrypoint) !== path.resolve(stableBin)) {
573
+ const command = scope === "system"
574
+ ? `sudo ${stableBin} service:install`
575
+ : `${stableBin} service:install --user`;
576
+ return {
577
+ ok: false,
578
+ message: `检测到 ${scope} 服务仍指向不稳定入口,已取消更新。请先运行: ${command}`,
579
+ detail: `entrypoint: ${entrypoint || "无法读取"}\nstable shim: ${stableBin}`,
580
+ };
581
+ }
582
+ }
583
+ return { ok: true, message: "服务入口预检通过" };
584
+ }
540
585
  function resolveWandBin(ctx) {
541
- // 更新后自修复:优先全局安装的 dist/cli.js,避免沿用旧源码路径。
586
+ // 更新后自修复:优先 npm 全局稳定 shim,避免沿用旧源码路径;不能钉死到包目录
587
+ // dist/cli.js,因为事务回滚切换 canonical 目录时 shim 才能指向同盘安全备份。
542
588
  if (ctx.preferGlobalBin) {
543
- const global = resolveGlobalWandCli();
544
- if (global)
545
- return global;
589
+ const globalBin = resolveGlobalWandBin();
590
+ if (globalBin)
591
+ return globalBin;
592
+ throw new Error("npm 全局 wand shim 不存在,拒绝把服务 unit 写到不稳定的包目录路径。");
546
593
  }
547
594
  if (ctx.wandBin && existsSync(ctx.wandBin))
548
595
  return ctx.wandBin;
@@ -1,3 +1,4 @@
1
+ import { type ServiceScope } from "./tui/commands.js";
1
2
  export interface DetachedUpdateOptions {
2
3
  installSpec: string;
3
4
  configPath: string;
@@ -14,5 +15,18 @@ export interface DetachedUpdateResult {
14
15
  pid?: number;
15
16
  message: string;
16
17
  }
18
+ /** A manifest alone is not enough: only a process actually launched by its manager may rely on auto-restart. */
19
+ export declare function resolveManagedServiceScope(installedScope: ServiceScope | null, platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv, pid?: number): ServiceScope | null;
20
+ export declare function resolveManagedServiceScopeCandidates(installedScopes: ServiceScope[], platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv, pid?: number): ServiceScope | null;
21
+ export declare function isStableServiceEntrypoint(entrypoint: string | null, stableBin: string | null): boolean;
22
+ export declare function checkManagedServiceUpdatePreflight(): {
23
+ ok: true;
24
+ } | {
25
+ ok: false;
26
+ message: string;
27
+ };
28
+ export declare function shouldUseSystemdRunForDetachedUpdate(serviceScope: ServiceScope | null): boolean;
29
+ /** Exported so the safety-critical update flow can be exercised without starting a detached process. */
30
+ export declare function buildDetachedUpdateHelperScript(opts: DetachedUpdateOptions, logPath: string, serviceScope: ServiceScope | null, updateUtilsPath?: string): string;
17
31
  export declare function startDetachedUpdateHelper(opts: DetachedUpdateOptions): DetachedUpdateResult;
18
32
  export declare function canUseDetachedUpdateHelper(): boolean;
@@ -1,66 +1,172 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { chmodSync, existsSync, mkdtempSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import process from "node:process";
6
- import { detectInstalledScope } from "./tui/commands.js";
6
+ import { fileURLToPath } from "node:url";
7
+ import { resolveGlobalWandBin } from "./npm-update-utils.js";
7
8
  function shellQuote(value) {
8
9
  return `'${value.replace(/'/g, `'\\''`)}'`;
9
10
  }
10
11
  function shellArray(values) {
11
12
  return values.map((value) => shellQuote(value)).join(" ");
12
13
  }
13
- function boolShell(value) {
14
- return value ? "1" : "0";
15
- }
16
14
  function resolveBashPath() {
17
15
  return existsSync("/bin/bash") ? "/bin/bash" : "bash";
18
16
  }
19
- function detectServiceScope() {
17
+ function detectInstalledServiceScopes() {
18
+ if (process.platform === "darwin") {
19
+ return [
20
+ existsSync("/Library/LaunchDaemons/com.wand.web.plist") ? "system" : null,
21
+ existsSync(path.join(os.homedir(), "Library/LaunchAgents/com.wand.web.plist")) ? "user" : null,
22
+ ].filter((scope) => scope !== null);
23
+ }
24
+ if (process.platform === "linux") {
25
+ return [
26
+ existsSync("/etc/systemd/system/wand.service") ? "system" : null,
27
+ existsSync(path.join(os.homedir(), ".config/systemd/user/wand.service")) ? "user" : null,
28
+ ].filter((scope) => scope !== null);
29
+ }
30
+ return [];
31
+ }
32
+ /** A manifest alone is not enough: only a process actually launched by its manager may rely on auto-restart. */
33
+ export function resolveManagedServiceScope(installedScope, platform = process.platform, env = process.env, pid = process.pid) {
34
+ if (platform === "darwin") {
35
+ return env.XPC_SERVICE_NAME === "com.wand.web" ? (installedScope ?? "system") : null;
36
+ }
37
+ if (platform === "linux") {
38
+ const hasSystemdPid = typeof env.SYSTEMD_EXEC_PID === "string" && env.SYSTEMD_EXEC_PID.length > 0;
39
+ const systemdPid = Number(env.SYSTEMD_EXEC_PID || "0");
40
+ const managed = hasSystemdPid
41
+ ? Number.isSafeInteger(systemdPid) && systemdPid === pid
42
+ : typeof env.INVOCATION_ID === "string" && env.INVOCATION_ID.length > 0;
43
+ return managed ? (installedScope ?? "system") : null;
44
+ }
45
+ return null;
46
+ }
47
+ export function resolveManagedServiceScopeCandidates(installedScopes, platform = process.platform, env = process.env, pid = process.pid) {
48
+ const installedScope = installedScopes.length === 1 ? installedScopes[0] : null;
49
+ const managedScope = resolveManagedServiceScope(installedScope, platform, env, pid);
50
+ if (managedScope && installedScopes.length > 1) {
51
+ throw new Error("同时检测到 system 与 user 两份 wand 服务,无法安全判断当前托管 scope;请先卸载其中一份。");
52
+ }
53
+ return managedScope;
54
+ }
55
+ function detectManagedServiceScope() {
56
+ return resolveManagedServiceScopeCandidates(detectInstalledServiceScopes());
57
+ }
58
+ export function isStableServiceEntrypoint(entrypoint, stableBin) {
59
+ if (!entrypoint || !stableBin)
60
+ return false;
20
61
  try {
21
- return detectInstalledScope();
62
+ return path.resolve(entrypoint) === path.resolve(stableBin);
22
63
  }
23
64
  catch {
24
- return null;
65
+ return false;
25
66
  }
26
67
  }
27
- function buildHelperScript(opts, logPath, serviceScope) {
28
- const isSystemdManaged = !!process.env.INVOCATION_ID;
29
- const isLaunchdManaged = !!process.env.LAUNCHD_SOCKET || !!process.env.XPC_SERVICE_NAME;
30
- const shouldStartService = serviceScope !== null;
31
- const shouldRestartStandalone = !shouldStartService;
32
- const npmCache = opts.env.npm_config_cache || path.join(os.homedir(), ".npm");
33
- const timeoutSec = Math.max(30, Math.ceil((opts.timeoutMs ?? 300_000) / 1000));
34
- const cliArgArray = opts.cliArgs;
68
+ function readManagedServiceEntrypoint(scope) {
69
+ if (process.platform === "darwin") {
70
+ const plist = scope === "system"
71
+ ? "/Library/LaunchDaemons/com.wand.web.plist"
72
+ : path.join(os.homedir(), "Library/LaunchAgents/com.wand.web.plist");
73
+ const result = spawnSync("plutil", ["-extract", "ProgramArguments.1", "raw", "-o", "-", plist], { encoding: "utf8", timeout: 5_000 });
74
+ return result.status === 0 ? (result.stdout || "").trim() || null : null;
75
+ }
76
+ if (process.platform === "linux") {
77
+ const unit = scope === "system"
78
+ ? "/etc/systemd/system/wand.service"
79
+ : path.join(os.homedir(), ".config/systemd/user/wand.service");
80
+ try {
81
+ const execStart = readFileSync(unit, "utf8").split("\n").find((line) => line.startsWith("ExecStart="));
82
+ if (!execStart)
83
+ return null;
84
+ const tokens = execStart.slice("ExecStart=".length).trim().split(/\s+/);
85
+ return tokens[1] || null;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+ function managedServiceUpdatePreflight(scope) {
94
+ const stableBin = resolveGlobalWandBin();
95
+ const entrypoint = readManagedServiceEntrypoint(scope);
96
+ if (isStableServiceEntrypoint(entrypoint, stableBin))
97
+ return { ok: true };
98
+ const command = scope === "system"
99
+ ? `sudo ${stableBin || "wand"} service:install -c <config-path>`
100
+ : `${stableBin || "wand"} service:install --user -c <config-path>`;
101
+ return {
102
+ ok: false,
103
+ message: `检测到 ${scope} 服务仍指向不稳定入口 (${entrypoint || "无法读取"}),已取消 Web 更新以保持服务在线。` +
104
+ `请先在终端重注册到全局 shim:${command}`,
105
+ };
106
+ }
107
+ export function checkManagedServiceUpdatePreflight() {
108
+ try {
109
+ const scope = detectManagedServiceScope();
110
+ return scope ? managedServiceUpdatePreflight(scope) : { ok: true };
111
+ }
112
+ catch (err) {
113
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
114
+ }
115
+ }
116
+ export function shouldUseSystemdRunForDetachedUpdate(serviceScope) {
117
+ return serviceScope !== null;
118
+ }
119
+ const DEFAULT_UPDATE_UTILS_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "npm-update-utils.js");
120
+ /** Exported so the safety-critical update flow can be exercised without starting a detached process. */
121
+ export function buildDetachedUpdateHelperScript(opts, logPath, serviceScope, updateUtilsPath = DEFAULT_UPDATE_UTILS_PATH) {
122
+ if (!Number.isSafeInteger(opts.parentPid) || opts.parentPid <= 1) {
123
+ throw new Error(`Invalid detached update parent pid: ${opts.parentPid}`);
124
+ }
125
+ const home = opts.env.HOME || os.homedir();
126
+ const npmCache = opts.env.npm_config_cache || path.join(home, ".npm");
127
+ const requestedTimeout = opts.timeoutMs ?? 300_000;
128
+ const timeoutMs = Number.isFinite(requestedTimeout)
129
+ ? Math.max(30_000, Math.trunc(requestedTimeout))
130
+ : 300_000;
35
131
  return `#!/usr/bin/env bash
36
- set -u
132
+ set -euo pipefail
37
133
  LOG=${shellQuote(logPath)}
134
+ INSTALL_SPEC=${shellQuote(opts.installSpec)}
135
+ PARENT_PID=${opts.parentPid}
38
136
  exec >>"$LOG" 2>&1
39
- echo "[wand-update] started at $(date -Is)"
40
- echo "[wand-update] parent pid: ${opts.parentPid}"
41
- echo "[wand-update] install spec: ${opts.installSpec}"
137
+ echo "[wand-update] started at $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
138
+ echo "[wand-update] parent pid: $PARENT_PID"
139
+ echo "[wand-update] install spec: $INSTALL_SPEC"
42
140
 
43
141
  export PATH=${shellQuote(opts.env.PATH || process.env.PATH || "")}
44
- export HOME=${shellQuote(opts.env.HOME || os.homedir())}
142
+ export HOME=${shellQuote(home)}
45
143
  export npm_config_cache=${shellQuote(npmCache)}
46
144
  CONFIG_PATH=${shellQuote(opts.configPath)}
47
- INSTALL_SPEC=${shellQuote(opts.installSpec)}
48
- PARENT_PID=${opts.parentPid}
49
145
  SERVICE_SCOPE=${shellQuote(serviceScope ?? "")}
50
- SHOULD_START_SERVICE=${boolShell(shouldStartService)}
51
- SHOULD_RESTART_STANDALONE=${boolShell(shouldRestartStandalone)}
52
- TIMEOUT_SEC=${timeoutSec}
53
- CLI_ARGS=(${shellArray(cliArgArray)})
146
+ TIMEOUT_MS=${timeoutMs}
147
+ NODE_BIN=${shellQuote(process.execPath)}
148
+ UPDATE_UTILS=${shellQuote(updateUtilsPath)}
149
+ WORKING_DIRECTORY=${shellQuote(opts.cwd)}
150
+ CLI_ARGS=(${shellArray(opts.cliArgs)})
151
+ GLOBAL_CLI=""
152
+ PARENT_EXITED=0
54
153
 
55
154
  run() {
56
155
  echo "+ $*"
57
156
  "$@"
58
157
  }
59
158
 
60
- run_best_effort() {
61
- echo "+ $*"
62
- "$@" || true
159
+ on_exit() {
160
+ local status="$?"
161
+ if [ "$status" -ne 0 ]; then
162
+ if [ "$PARENT_EXITED" = "0" ]; then
163
+ echo "[wand-update] failed with exit code $status; parent service was left running"
164
+ else
165
+ echo "[wand-update] failed with exit code $status after parent service exited"
166
+ fi
167
+ fi
63
168
  }
169
+ trap on_exit EXIT
64
170
 
65
171
  wait_for_parent_exit() {
66
172
  local pid="$1"
@@ -68,84 +174,68 @@ wait_for_parent_exit() {
68
174
  while kill -0 "$pid" 2>/dev/null; do
69
175
  i=$((i + 1))
70
176
  if [ "$i" -ge 120 ]; then
71
- echo "[wand-update] parent still alive after 60s; continuing"
72
- return 0
177
+ echo "[wand-update] parent still alive after SIGTERM timeout"
178
+ return 1
73
179
  fi
74
180
  sleep 0.5
75
181
  done
76
182
  }
77
183
 
78
- clean_leftovers() {
184
+ resolve_global_cli() {
79
185
  local npm_root
80
- npm_root="$(npm root -g 2>/dev/null || true)"
81
- if [ -n "$npm_root" ] && [ -d "$npm_root/@co0ontty" ]; then
82
- find "$npm_root/@co0ontty" -maxdepth 1 -name ".wand-*" -type d -print -exec rm -rf {} + 2>/dev/null || true
186
+ npm_root="$(npm root -g)"
187
+ GLOBAL_CLI="$npm_root/@co0ontty/wand/dist/cli.js"
188
+ if [ ! -f "$GLOBAL_CLI" ]; then
189
+ echo "[wand-update] global CLI not found after install: $GLOBAL_CLI"
190
+ return 1
83
191
  fi
84
192
  }
85
193
 
86
- npm_install_wand() {
87
- if command -v timeout >/dev/null 2>&1; then
88
- run timeout "$TIMEOUT_SEC" npm install -g "$INSTALL_SPEC"
89
- else
90
- run npm install -g "$INSTALL_SPEC"
91
- fi
92
- }
194
+ main() {
195
+ echo "[wand-update] installing while parent service stays online"
196
+ if run "$NODE_BIN" --input-type=module - "$UPDATE_UTILS" "$INSTALL_SPEC" "$TIMEOUT_MS" <<'WAND_INSTALL_NODE'
197
+ import { pathToFileURL } from "node:url";
93
198
 
94
- npm_install_wand_force() {
95
- if command -v timeout >/dev/null 2>&1; then
96
- run timeout "$TIMEOUT_SEC" npm install -g --force "$INSTALL_SPEC"
199
+ const [modulePath, installSpec, timeoutValue] = process.argv.slice(2);
200
+ const timeoutMs = Number(timeoutValue);
201
+ const updateUtils = await import(pathToFileURL(modulePath).href);
202
+ if (typeof updateUtils.installPackageGloballyAsync !== "function") {
203
+ throw new Error("installPackageGloballyAsync is unavailable in " + modulePath);
204
+ }
205
+ await updateUtils.installPackageGloballyAsync(installSpec, timeoutMs, (line) => {
206
+ process.stdout.write(String(line) + "\\n");
207
+ });
208
+ WAND_INSTALL_NODE
209
+ then
210
+ echo "[wand-update] install completed"
97
211
  else
98
- run npm install -g --force "$INSTALL_SPEC"
212
+ local install_status="$?"
213
+ echo "[wand-update] install failed; keeping parent service online"
214
+ return "$install_status"
99
215
  fi
100
- }
101
216
 
102
- restart_or_start_service() {
103
- if [ "$SHOULD_START_SERVICE" = "1" ]; then
104
- if [ "$SERVICE_SCOPE" = "user" ]; then
105
- run_best_effort wand service:install --user -c "$CONFIG_PATH"
106
- run_best_effort systemctl --user restart wand.service
107
- else
108
- run_best_effort wand service:install -c "$CONFIG_PATH"
109
- run_best_effort systemctl restart wand.service
110
- fi
111
- return
112
- fi
217
+ resolve_global_cli
218
+ echo "[wand-update] initializing updated installation"
219
+ run "$NODE_BIN" "$GLOBAL_CLI" init -c "$CONFIG_PATH"
113
220
 
114
- if [ "$SHOULD_RESTART_STANDALONE" = "1" ]; then
115
- echo "[wand-update] starting standalone process"
116
- cd ${shellQuote(opts.cwd)}
117
- local global_cli
118
- global_cli="$(npm root -g 2>/dev/null)/@co0ontty/wand/dist/cli.js"
119
- if [ -f "$global_cli" ]; then
120
- nohup ${shellQuote(process.execPath)} "$global_cli" "$\{CLI_ARGS[@]}" >>"$LOG" 2>&1 &
121
- else
122
- nohup wand "$\{CLI_ARGS[@]}" >>"$LOG" 2>&1 &
123
- fi
124
- fi
125
- }
126
-
127
- main() {
128
- if [ "$SERVICE_SCOPE" = "user" ]; then
129
- run_best_effort systemctl --user stop wand.service
130
- elif [ "$SERVICE_SCOPE" = "system" ]; then
131
- run_best_effort systemctl stop wand.service
221
+ if kill -0 "$PARENT_PID" 2>/dev/null; then
222
+ echo "[wand-update] sending SIGTERM to parent pid $PARENT_PID"
223
+ run kill -TERM "$PARENT_PID"
224
+ wait_for_parent_exit "$PARENT_PID"
225
+ else
226
+ echo "[wand-update] parent pid $PARENT_PID already exited"
132
227
  fi
228
+ PARENT_EXITED=1
133
229
 
134
- wait_for_parent_exit "$PARENT_PID"
135
- clean_leftovers
136
-
137
- echo "[wand-update] installing"
138
- if ! npm_install_wand; then
139
- echo "[wand-update] first install failed; retrying with uninstall + force install"
140
- clean_leftovers
141
- run_best_effort npm uninstall -g @co0ontty/wand
142
- clean_leftovers
143
- npm_install_wand_force
230
+ if [ -n "$SERVICE_SCOPE" ]; then
231
+ echo "[wand-update] $SERVICE_SCOPE service detected; waiting for Restart/KeepAlive relaunch"
232
+ else
233
+ echo "[wand-update] starting updated standalone process"
234
+ cd "$WORKING_DIRECTORY"
235
+ nohup "$NODE_BIN" "$GLOBAL_CLI" "$\{CLI_ARGS[@]}" >>"$LOG" 2>&1 </dev/null &
236
+ echo "[wand-update] standalone pid: $!"
144
237
  fi
145
-
146
- run wand init -c "$CONFIG_PATH"
147
- restart_or_start_service
148
- echo "[wand-update] completed at $(date -Is)"
238
+ echo "[wand-update] completed at $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
149
239
  }
150
240
 
151
241
  main
@@ -179,15 +269,15 @@ function spawnDetached(scriptPath, _logPath, env, serviceScope) {
179
269
  });
180
270
  return res.status === 0 ? { method } : null;
181
271
  };
182
- const userService = runTransient(["--user", `--unit=${unitName}`, "--quiet", "--collect", bashPath, scriptPath], "systemd-run --user") ??
183
- runTransient(["--user", `--unit=${unitName}`, "--quiet", bashPath, scriptPath], "systemd-run --user");
184
- if (userService)
185
- return userService;
186
- const systemService = runTransient([`--unit=${unitName}`, "--quiet", "--collect", bashPath, scriptPath], "systemd-run") ??
187
- runTransient([`--unit=${unitName}`, "--quiet", bashPath, scriptPath], "systemd-run");
188
- if (systemService)
189
- return systemService;
190
- if (serviceScope) {
272
+ if (shouldUseSystemdRunForDetachedUpdate(serviceScope)) {
273
+ const userService = runTransient(["--user", `--unit=${unitName}`, "--quiet", "--collect", bashPath, scriptPath], "systemd-run --user") ??
274
+ runTransient(["--user", `--unit=${unitName}`, "--quiet", bashPath, scriptPath], "systemd-run --user");
275
+ if (userService)
276
+ return userService;
277
+ const systemService = runTransient([`--unit=${unitName}`, "--quiet", "--collect", bashPath, scriptPath], "systemd-run") ??
278
+ runTransient([`--unit=${unitName}`, "--quiet", bashPath, scriptPath], "systemd-run");
279
+ if (systemService)
280
+ return systemService;
191
281
  return null;
192
282
  }
193
283
  const setsid = spawnSync("setsid", ["--version"], { encoding: "utf8", timeout: 2000 });
@@ -208,8 +298,33 @@ export function startDetachedUpdateHelper(opts) {
208
298
  const dir = mkdtempSync(path.join(os.tmpdir(), "wand-update-"));
209
299
  const scriptPath = path.join(dir, "update.sh");
210
300
  const logPath = path.join(dir, "update.log");
211
- const serviceScope = detectServiceScope();
212
- writeFileSync(scriptPath, buildHelperScript(opts, logPath, serviceScope), { encoding: "utf8", mode: 0o700 });
301
+ let serviceScope;
302
+ try {
303
+ serviceScope = detectManagedServiceScope();
304
+ }
305
+ catch (err) {
306
+ return {
307
+ started: false,
308
+ scriptPath,
309
+ logPath,
310
+ message: err instanceof Error ? err.message : String(err),
311
+ };
312
+ }
313
+ if (serviceScope) {
314
+ const preflight = managedServiceUpdatePreflight(serviceScope);
315
+ if (!preflight.ok) {
316
+ return {
317
+ started: false,
318
+ scriptPath,
319
+ logPath,
320
+ message: preflight.message.replace("<config-path>", opts.configPath),
321
+ };
322
+ }
323
+ }
324
+ writeFileSync(scriptPath, buildDetachedUpdateHelperScript(opts, logPath, serviceScope), {
325
+ encoding: "utf8",
326
+ mode: 0o700,
327
+ });
213
328
  chmodSync(scriptPath, 0o700);
214
329
  const spawned = spawnDetached(scriptPath, logPath, opts.env, serviceScope);
215
330
  if (!spawned) {