@co0ontty/wand 2.13.0 → 3.0.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.
@@ -1,4 +1,3 @@
1
- import express from "express";
2
1
  import { SessionInputError } from "./process-manager.js";
3
2
  import { getDefaultModelForProvider, normalizeMode } from "./config.js";
4
3
  import { blockWindowMessagesForTransport, sliceTurnBlocksForTransport, truncateMessagesForTransport, windowMessagesForTransport } from "./message-truncator.js";
@@ -7,7 +6,6 @@ import { resolveSessionCwd } from "./session-cwd.js";
7
6
  import { resolveCommitAiContext } from "./session-ai-context.js";
8
7
  import { getGitStatus, QuickCommitError, runQuickCommitWithFallback, runTagHead, runPush, generateCommitMessageOnly, } from "./git-quick-commit.js";
9
8
  import { getErrorMessage } from "./error-utils.js";
10
- export { getErrorMessage };
11
9
  export function parseSessionCreationOrigin(body) {
12
10
  const rawSource = body?.sessionSource;
13
11
  if (rawSource !== undefined && rawSource !== "interactive" && rawSource !== "automation" && rawSource !== "startup") {
@@ -97,10 +95,6 @@ function removeFromHiddenClaudeSessionIds(storage, ids) {
97
95
  function getSessionById(processes, structured, id) {
98
96
  return structured.get(id) ?? processes.get(id);
99
97
  }
100
- function listAllSessions(processes, structured) {
101
- return [...structured.list(), ...processes.list()]
102
- .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
103
- }
104
98
  /** Lightweight session list — omits output and messages to reduce payload. */
105
99
  function listAllSessionsSlim(processes, structured) {
106
100
  return [...structured.listSlim(), ...processes.listSlim()]
@@ -115,7 +109,7 @@ function requireWorktreeSession(snapshot) {
115
109
  }
116
110
  return snapshot;
117
111
  }
118
- function buildWorktreeMergeInfo(current, status, info) {
112
+ function buildWorktreeMergeInfo(current, info) {
119
113
  return {
120
114
  ...(current.worktreeMergeInfo ?? null),
121
115
  ...(info ?? null),
@@ -124,7 +118,7 @@ function buildWorktreeMergeInfo(current, status, info) {
124
118
  };
125
119
  }
126
120
  function saveWorktreeMergeState(storage, current, status, info) {
127
- const mergedInfo = buildWorktreeMergeInfo(current, status, info);
121
+ const mergedInfo = buildWorktreeMergeInfo(current, info);
128
122
  const updated = {
129
123
  ...current,
130
124
  worktreeMergeStatus: status,
@@ -134,14 +128,7 @@ function saveWorktreeMergeState(storage, current, status, info) {
134
128
  return updated;
135
129
  }
136
130
  function getWorktreeMergeResponseStatus(error) {
137
- const code = getWorktreeMergeErrorCode(error);
138
- if (!code) {
139
- return 400;
140
- }
141
- if (code === "WORKTREE_MERGE_CONFLICT") {
142
- return 409;
143
- }
144
- return 400;
131
+ return getWorktreeMergeErrorCode(error) === "WORKTREE_MERGE_CONFLICT" ? 409 : 400;
145
132
  }
146
133
  function getWorktreeMergePayload(error, fallback) {
147
134
  if (error instanceof WorktreeMergeError) {
@@ -244,7 +231,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
244
231
  const all = listAllSessionsSlim(processes, structured);
245
232
  res.json(all);
246
233
  });
247
- app.post("/api/structured-sessions", express.json(), async (req, res) => {
234
+ app.post("/api/structured-sessions", async (req, res) => {
248
235
  const body = req.body;
249
236
  try {
250
237
  if (body.provider && body.provider !== "claude" && body.provider !== "codex" && body.provider !== "opencode") {
@@ -279,7 +266,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
279
266
  res.status(400).json({ error: getErrorMessage(error, "无法启动结构化会话。") });
280
267
  }
281
268
  });
282
- app.post("/api/sessions/:id/model", express.json(), (req, res) => {
269
+ app.post("/api/sessions/:id/model", (req, res) => {
283
270
  const body = req.body;
284
271
  const rawModel = typeof body?.model === "string" ? body.model.trim() : null;
285
272
  const id = req.params.id;
@@ -304,7 +291,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
304
291
  });
305
292
  // 思考深度切换:与 /model 路由对称。结构化会话立即影响下一条 prompt(CLI/SDK 各自接入
306
293
  // --effort / thinking budget),PTY 会话通过 /effort 更新当前 Claude 进程。
307
- app.post("/api/sessions/:id/thinking-effort", express.json(), (req, res) => {
294
+ app.post("/api/sessions/:id/thinking-effort", (req, res) => {
308
295
  const body = req.body;
309
296
  const raw = typeof body?.thinkingEffort === "string" ? body.thinkingEffort : null;
310
297
  const id = req.params.id;
@@ -328,7 +315,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
328
315
  // 执行模式切换:与 /model、/thinking-effort 路由对称。结构化会话立即影响下一条
329
316
  // prompt(权限策略 / 系统提示 / CLI flag 都按 session.mode 逐轮重新派生);PTY 会话
330
317
  // 仅更新 wand 自身的权限自动放行判定,已启动的 claude 进程命令行 flag 不变。codex 锁 full-access。
331
- app.post("/api/sessions/:id/mode", express.json(), (req, res) => {
318
+ app.post("/api/sessions/:id/mode", (req, res) => {
332
319
  const body = req.body;
333
320
  const raw = typeof body?.mode === "string" ? body.mode.trim() : "";
334
321
  if (!raw) {
@@ -365,7 +352,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
365
352
  }
366
353
  res.json({ id: snapshot.id, messages: snapshot.messages ?? [] });
367
354
  });
368
- app.post("/api/structured-sessions/:id/messages", express.json(), async (req, res) => {
355
+ app.post("/api/structured-sessions/:id/messages", async (req, res) => {
369
356
  const input = String(req.body?.input ?? "");
370
357
  const interrupt = !!req.body?.interrupt;
371
358
  // preserveQueue: 仅在 interrupt 路径有意义。排队条「立即」会带这个 flag,
@@ -388,7 +375,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
388
375
  // ── Structured queued-messages management ──
389
376
  // 这些端点构成"排队消息条"的后端操作面:reorder、立即发送、单条删除、全部清空。
390
377
  // 全部走乐观更新模型,失败时前端会回滚到上一次 WS 推送的 queuedMessages。
391
- app.patch("/api/structured-sessions/:id/queued", express.json(), (req, res) => {
378
+ app.patch("/api/structured-sessions/:id/queued", (req, res) => {
392
379
  const rawOrder = req.body?.order;
393
380
  if (!Array.isArray(rawOrder)) {
394
381
  res.status(400).json({ error: "缺少 order 数组。" });
@@ -416,7 +403,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
416
403
  res.status(400).json({ error: getErrorMessage(error, "无法删除排队消息。") });
417
404
  }
418
405
  });
419
- app.post("/api/structured-sessions/:id/queued/:index/promote", express.json(), async (req, res) => {
406
+ app.post("/api/structured-sessions/:id/queued/:index/promote", async (req, res) => {
420
407
  const index = Number(req.params.index);
421
408
  if (!Number.isInteger(index)) {
422
409
  res.status(400).json({ error: "下标无效。" });
@@ -498,7 +485,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
498
485
  res.status(getWorktreeMergeResponseStatus(error)).json(getWorktreeMergePayload(error, "无法检查 worktree 合并状态。"));
499
486
  }
500
487
  });
501
- app.post("/api/sessions/:id/worktree/merge", express.json(), (req, res) => {
488
+ app.post("/api/sessions/:id/worktree/merge", (req, res) => {
502
489
  try {
503
490
  const current = requireWorktreeSession(getLatestSessionSnapshot(processes, structured, storage, req.params.id));
504
491
  if (!isMergeActionAllowed(current)) {
@@ -558,7 +545,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
558
545
  res.json({ isGit: false, error: getErrorMessage(error, "无法读取 git 状态。") });
559
546
  }
560
547
  });
561
- app.post("/api/sessions/:id/quick-commit", express.json(), async (req, res) => {
548
+ app.post("/api/sessions/:id/quick-commit", async (req, res) => {
562
549
  const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
563
550
  if (!snapshot) {
564
551
  res.status(404).json({ error: "未找到该会话。" });
@@ -593,7 +580,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
593
580
  res.status(400).json({ error: getErrorMessage(error, "快捷提交失败。") });
594
581
  }
595
582
  });
596
- app.post("/api/sessions/:id/generate-commit-message", express.json(), async (req, res) => {
583
+ app.post("/api/sessions/:id/generate-commit-message", async (req, res) => {
597
584
  const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
598
585
  if (!snapshot) {
599
586
  res.status(404).json({ error: "未找到该会话。" });
@@ -618,7 +605,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
618
605
  res.status(400).json({ error: getErrorMessage(error, "生成 commit message 失败。") });
619
606
  }
620
607
  });
621
- app.post("/api/sessions/:id/git/tag-head", express.json(), async (req, res) => {
608
+ app.post("/api/sessions/:id/git/tag-head", async (req, res) => {
622
609
  const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
623
610
  if (!snapshot) {
624
611
  res.status(404).json({ error: "未找到该会话。" });
@@ -650,7 +637,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
650
637
  res.status(400).json({ error: getErrorMessage(error, "打 tag 失败。") });
651
638
  }
652
639
  });
653
- app.post("/api/sessions/:id/git/push", express.json(), async (req, res) => {
640
+ app.post("/api/sessions/:id/git/push", async (req, res) => {
654
641
  const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
655
642
  if (!snapshot) {
656
643
  res.status(404).json({ error: "未找到该会话。" });
@@ -699,7 +686,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
699
686
  res.status(getWorktreeMergeResponseStatus(error)).json(getWorktreeMergePayload(error, "无法清理 worktree。"));
700
687
  }
701
688
  });
702
- app.post("/api/sessions/batch-delete", express.json(), (req, res) => {
689
+ app.post("/api/sessions/batch-delete", (req, res) => {
703
690
  const sessionIds = Array.isArray(req.body?.sessionIds)
704
691
  ? req.body.sessionIds.filter((value) => typeof value === "string" && value.trim().length > 0)
705
692
  : [];
@@ -923,7 +910,7 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
923
910
  res.status(response.statusCode).json(response.payload);
924
911
  }
925
912
  });
926
- app.post("/api/codex-sessions/:threadId/resume", express.json(), async (req, res) => {
913
+ app.post("/api/codex-sessions/:threadId/resume", async (req, res) => {
927
914
  const threadId = String(req.params.threadId || "").trim();
928
915
  const body = req.body;
929
916
  try {
@@ -1121,7 +1108,7 @@ export function registerClaudeHistoryRoutes(app, processes, storage) {
1121
1108
  res.status(500).json({ error: getErrorMessage(error, "无法删除该目录下的历史会话。") });
1122
1109
  }
1123
1110
  });
1124
- app.post("/api/claude-history/batch-delete", express.json(), (req, res) => {
1111
+ app.post("/api/claude-history/batch-delete", (req, res) => {
1125
1112
  const claudeSessionIds = Array.isArray(req.body?.claudeSessionIds)
1126
1113
  ? req.body.claudeSessionIds.filter((value) => typeof value === "string" && value.trim().length > 0)
1127
1114
  : [];
@@ -1204,7 +1191,7 @@ export function registerClaudeHistoryRoutes(app, processes, storage) {
1204
1191
  }
1205
1192
  res.json({ ok: true });
1206
1193
  });
1207
- app.post("/api/codex-history/batch-delete", express.json(), (req, res) => {
1194
+ app.post("/api/codex-history/batch-delete", (req, res) => {
1208
1195
  const threadIds = Array.isArray(req.body?.claudeSessionIds)
1209
1196
  ? req.body.claudeSessionIds.filter((value) => typeof value === "string" && value.trim().length > 0)
1210
1197
  : [];
package/dist/server.js CHANGED
@@ -20,7 +20,8 @@ import { getCachedModels, refreshModels } from "./models.js";
20
20
  import { ProcessManager } from "./process-manager.js";
21
21
  import { SessionLogger } from "./session-logger.js";
22
22
  import { StructuredSessionManager } from "./structured-session-manager.js";
23
- import { getErrorMessage, parseSessionCreationOrigin, registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
23
+ import { parseSessionCreationOrigin, registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
24
+ import { getErrorMessage } from "./error-utils.js";
24
25
  import { checkPackageUpdateAsync, installPackageGloballyAsync, normalizeUpdateChannel, resolveGlobalWandCli, } from "./npm-update-utils.js";
25
26
  import { repairServiceUnitAfterUpdate } from "./service-self-repair.js";
26
27
  import { computeRelaunch } from "./relaunch.js";
@@ -604,22 +605,6 @@ function resolveAppConnectOrigin(origin, config) {
604
605
  return origin;
605
606
  }
606
607
  }
607
- function decodeConnectCode(code) {
608
- try {
609
- const decoded = Buffer.from(code, "base64").toString("utf8");
610
- const hashIdx = decoded.lastIndexOf("#");
611
- if (hashIdx < 1)
612
- return null;
613
- const url = decoded.substring(0, hashIdx);
614
- const token = decoded.substring(hashIdx + 1);
615
- if (!url.startsWith("http") || token.length < 16)
616
- return null;
617
- return { url, token };
618
- }
619
- catch {
620
- return null;
621
- }
622
- }
623
608
  /** Match a semver-looking token in a file name (with optional pre-release / build metadata). */
624
609
  function resolveAndroidApkDir(configDir, config) {
625
610
  const configuredDir = config.android?.apkDir?.trim();
@@ -1121,7 +1106,6 @@ export async function startServer(config, configPath) {
1121
1106
  const useHttps = config.https === true;
1122
1107
  const protocol = useHttps ? "https" : "http";
1123
1108
  const requireAuth = buildRequireAuth(useHttps, storage, config);
1124
- const nodeModulesDir = path.join(RUNTIME_ROOT_DIR, "node_modules");
1125
1109
  app.use(express.json({ limit: "1mb" }));
1126
1110
  app.use(compression({ threshold: 1024 }));
1127
1111
  app.use((req, res, next) => {
@@ -1827,7 +1811,7 @@ export async function startServer(config, configPath) {
1827
1811
  res.status(500).json({ error: getErrorMessage(error, "检查 CLI 更新失败。") });
1828
1812
  }
1829
1813
  });
1830
- app.post("/api/provider-cli-updates", express.json(), async (req, res) => {
1814
+ app.post("/api/provider-cli-updates", async (req, res) => {
1831
1815
  if (providerCliUpdateInFlight || updateInFlight) {
1832
1816
  res.status(409).json({ error: "CLI 更新正在进行中,请稍候。" });
1833
1817
  return;
@@ -2616,7 +2600,7 @@ export async function startServer(config, configPath) {
2616
2600
  const cli = storage.getConfigValue("autoUpdateProviderClis") === "true";
2617
2601
  res.json({ web, apk, dmg, cli });
2618
2602
  });
2619
- app.post("/api/auto-update", express.json(), (req, res) => {
2603
+ app.post("/api/auto-update", (req, res) => {
2620
2604
  const { web, apk, dmg, cli } = req.body;
2621
2605
  if (typeof web === "boolean") {
2622
2606
  storage.setConfigValue("autoUpdateWeb", String(web));
@@ -2649,7 +2633,7 @@ export async function startServer(config, configPath) {
2649
2633
  },
2650
2634
  });
2651
2635
  });
2652
- app.post("/api/update-channel", express.json(), (req, res) => {
2636
+ app.post("/api/update-channel", (req, res) => {
2653
2637
  const body = (req.body ?? {});
2654
2638
  const channel = body.channel === "beta" ? "beta" : "stable";
2655
2639
  storage.setConfigValue("updateChannel", channel);
package/dist/storage.d.ts CHANGED
@@ -58,6 +58,5 @@ export declare class WandStorage {
58
58
  getLatestSessionByClaudeSessionId(claudeSessionId: string): SessionSnapshot | null;
59
59
  loadSessions(): SessionSnapshot[];
60
60
  private mapSessionRow;
61
- updateSessionWorktreeMergeState(id: string, status: SessionSnapshot["worktreeMergeStatus"], info: SessionSnapshot["worktreeMergeInfo"]): SessionSnapshot | null;
62
61
  deleteSession(id: string): void;
63
62
  }
package/dist/storage.js CHANGED
@@ -577,19 +577,6 @@ export class WandStorage {
577
577
  mapSessionRow(row) {
578
578
  return mapSessionCore(row);
579
579
  }
580
- updateSessionWorktreeMergeState(id, status, info) {
581
- const current = this.getSession(id);
582
- if (!current) {
583
- return null;
584
- }
585
- const updated = {
586
- ...current,
587
- worktreeMergeStatus: status,
588
- worktreeMergeInfo: info,
589
- };
590
- this.saveSessionMetadata(updated);
591
- return updated;
592
- }
593
580
  deleteSession(id) {
594
581
  this.db.prepare("DELETE FROM command_sessions WHERE id = ?").run(id);
595
582
  }
@@ -4,7 +4,6 @@ import { ContentBlock, ExecutionMode, ProcessEvent, SessionProvider, SessionRunn
4
4
  interface CreateStructuredSessionOptions {
5
5
  cwd: string;
6
6
  mode: ExecutionMode;
7
- prompt?: string;
8
7
  provider?: SessionProvider;
9
8
  runner?: SessionRunner;
10
9
  worktreeEnabled?: boolean;
@@ -925,7 +925,6 @@ export class StructuredSessionManager {
925
925
  createSession(options) {
926
926
  const id = randomUUID();
927
927
  const startedAt = new Date().toISOString();
928
- const prompt = options.prompt?.trim();
929
928
  const provider = options.provider === "codex" || options.provider === "opencode" ? options.provider : "claude";
930
929
  const runner = options.runner ?? defaultStructuredRunner(provider);
931
930
  const baseCwd = resolveSessionCwd(options.cwd, this.config.defaultCwd);
@@ -3179,7 +3178,6 @@ export class StructuredSessionManager {
3179
3178
  killedForAskUserQuestion,
3180
3179
  sessionId: turnState.sessionId,
3181
3180
  });
3182
- const interruptedByUser = this.interruptedWith.has(sessionId);
3183
3181
  const msgs = this.buildCompletedAssistantMessages(current, turnState);
3184
3182
  const interruptPrompt = this.interruptedWith.get(sessionId);
3185
3183
  const keepRunning = killedForAskUserQuestion || !!interruptPrompt;
@@ -195,6 +195,7 @@ SERVICE_SCOPE=${shellQuote(serviceScope ?? "")}
195
195
  TIMEOUT_MS=${timeoutMs}
196
196
  NODE_BIN=${shellQuote(process.execPath)}
197
197
  UPDATE_UTILS=${shellQuote(updateUtilsPath)}
198
+ GLOBAL_CLI_PATH_FILE=${shellQuote(`${logPath}.cli-path`)}
198
199
  WORKING_DIRECTORY=${shellQuote(opts.cwd)}
199
200
  CLI_ARGS=(${shellArray(opts.cliArgs)})
200
201
  GLOBAL_CLI=""
@@ -207,6 +208,7 @@ run() {
207
208
 
208
209
  on_exit() {
209
210
  local status="$?"
211
+ rm -f "$GLOBAL_CLI_PATH_FILE" 2>/dev/null || true
210
212
  if [ "$status" -ne 0 ]; then
211
213
  if [ "$PARENT_EXITED" = "0" ]; then
212
214
  echo "[wand-update] failed with exit code $status; parent service was left running"
@@ -230,22 +232,13 @@ wait_for_parent_exit() {
230
232
  done
231
233
  }
232
234
 
233
- resolve_global_cli() {
234
- local npm_root
235
- npm_root="$(npm root -g)"
236
- GLOBAL_CLI="$npm_root/@co0ontty/wand/dist/cli.js"
237
- if [ ! -f "$GLOBAL_CLI" ]; then
238
- echo "[wand-update] global CLI not found after install: $GLOBAL_CLI"
239
- return 1
240
- fi
241
- }
242
-
243
235
  main() {
244
236
  echo "[wand-update] installing while parent service stays online"
245
- if run "$NODE_BIN" ${nodeLoaderCommand}--input-type=module - "$UPDATE_UTILS" "$INSTALL_SPEC" "$TIMEOUT_MS" <<'WAND_INSTALL_NODE'
237
+ if run "$NODE_BIN" ${nodeLoaderCommand}--input-type=module - "$UPDATE_UTILS" "$INSTALL_SPEC" "$TIMEOUT_MS" "$GLOBAL_CLI_PATH_FILE" <<'WAND_INSTALL_NODE'
238
+ import { realpathSync, statSync, writeFileSync } from "node:fs";
246
239
  import { pathToFileURL } from "node:url";
247
240
 
248
- const [modulePath, installSpec, timeoutValue] = process.argv.slice(2);
241
+ const [modulePath, installSpec, timeoutValue, cliPathFile] = process.argv.slice(2);
249
242
  const timeoutMs = Number(timeoutValue);
250
243
  const updateUtils = await import(pathToFileURL(modulePath).href);
251
244
  const installPackageGloballyAsync = updateUtils.installPackageGloballyAsync
@@ -253,19 +246,37 @@ const installPackageGloballyAsync = updateUtils.installPackageGloballyAsync
253
246
  if (typeof installPackageGloballyAsync !== "function") {
254
247
  throw new Error("installPackageGloballyAsync is unavailable in " + modulePath);
255
248
  }
256
- await installPackageGloballyAsync(installSpec, timeoutMs, (line) => {
249
+ const installedCli = await installPackageGloballyAsync(installSpec, timeoutMs, (line) => {
257
250
  process.stdout.write(String(line) + "\\n");
258
251
  });
252
+ if (typeof installedCli !== "string" || installedCli.trim().length === 0) {
253
+ throw new Error("installer did not return the installed Wand CLI path");
254
+ }
255
+ const resolvedCli = realpathSync(installedCli.trim());
256
+ if (!statSync(resolvedCli).isFile()) {
257
+ throw new Error("installed Wand CLI is not a file: " + resolvedCli);
258
+ }
259
+ writeFileSync(cliPathFile, resolvedCli, { encoding: "utf8", mode: 0o600 });
259
260
  WAND_INSTALL_NODE
260
261
  then
261
262
  echo "[wand-update] install completed"
262
263
  else
263
264
  local install_status="$?"
264
- echo "[wand-update] install failed; keeping parent service online"
265
+ echo "[wand-update] install or CLI resolution failed; keeping parent service online"
265
266
  return "$install_status"
266
267
  fi
267
268
 
268
- resolve_global_cli
269
+ if [ ! -f "$GLOBAL_CLI_PATH_FILE" ]; then
270
+ echo "[wand-update] installer did not persist the global CLI path"
271
+ return 1
272
+ fi
273
+ GLOBAL_CLI="$(<"$GLOBAL_CLI_PATH_FILE")"
274
+ rm -f "$GLOBAL_CLI_PATH_FILE"
275
+ if [ ! -f "$GLOBAL_CLI" ]; then
276
+ echo "[wand-update] global CLI not found after install: $GLOBAL_CLI"
277
+ return 1
278
+ fi
279
+ echo "[wand-update] resolved global CLI: $GLOBAL_CLI"
269
280
  echo "[wand-update] initializing updated installation"
270
281
  run "$NODE_BIN" "$GLOBAL_CLI" init -c "$CONFIG_PATH"
271
282
 
@@ -349,8 +360,11 @@ function spawnDetached(scriptPath, _logPath, env, serviceScope) {
349
360
  }
350
361
  }
351
362
  if (process.platform === "darwin") {
352
- const nohupPath = existsSync("/usr/bin/nohup") ? "/usr/bin/nohup" : "nohup";
353
- const result = trySpawn(nohupPath, [resolveBashPath(), scriptPath], "nohup");
363
+ // `detached: true` already creates an independent process group. Wrapping the
364
+ // helper in /usr/bin/nohup breaks when Wand itself is a launchd daemon:
365
+ // nohup exits immediately because it cannot adopt a Background session, while
366
+ // spawn() has already returned a PID and the API falsely reports success.
367
+ const result = trySpawn(resolveBashPath(), [scriptPath], "bash detached");
354
368
  if (result)
355
369
  return result;
356
370
  }
@@ -1,4 +1,4 @@
1
- import { mkdirSync, existsSync } from "node:fs";
1
+ import { mkdirSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import multer from "multer";
@@ -16,9 +16,7 @@ export function registerUploadRoutes(app, processes) {
16
16
  return;
17
17
  }
18
18
  const uploadDir = path.join(cwd, ".wand-uploads");
19
- if (!existsSync(uploadDir)) {
20
- mkdirSync(uploadDir, { recursive: true });
21
- }
19
+ mkdirSync(uploadDir, { recursive: true });
22
20
  cb(null, uploadDir);
23
21
  },
24
22
  filename(_req, file, cb) {
@@ -19,11 +19,9 @@ export function extractSemver(text) {
19
19
  export function compareSemver(a, b) {
20
20
  const pa = parseSemverLike(a);
21
21
  const pb = parseSemverLike(b);
22
- for (let i = 0; i < 3; i++) {
23
- const diff = (pa.mainParts[i] || 0) - (pb.mainParts[i] || 0);
24
- if (diff !== 0)
25
- return diff;
26
- }
22
+ const mainOrder = compareMainParts(pa.mainParts, pb.mainParts);
23
+ if (mainOrder !== 0)
24
+ return mainOrder;
27
25
  // Main version equal — apply semver prerelease rule: no prerelease > with prerelease.
28
26
  if (!pa.pre && pb.pre)
29
27
  return 1;
@@ -80,17 +78,23 @@ function comparePrereleaseSegments(preA, preB) {
80
78
  export function compareApkInstallOrder(a, b) {
81
79
  const pa = parseSemverLike(a);
82
80
  const pb = parseSemverLike(b);
83
- for (let i = 0; i < 3; i++) {
84
- const diff = (pa.mainParts[i] || 0) - (pb.mainParts[i] || 0);
85
- if (diff !== 0)
86
- return diff;
87
- }
81
+ const mainOrder = compareMainParts(pa.mainParts, pb.mainParts);
82
+ if (mainOrder !== 0)
83
+ return mainOrder;
88
84
  if (pa.isDebug !== pb.isDebug)
89
85
  return pa.isDebug ? 1 : -1;
90
86
  if (pa.isDebug && pb.isDebug)
91
87
  return comparePrereleaseSegments(pa.pre, pb.pre);
92
88
  return 0;
93
89
  }
90
+ function compareMainParts(a, b) {
91
+ for (let i = 0; i < 3; i++) {
92
+ const diff = (a[i] || 0) - (b[i] || 0);
93
+ if (diff !== 0)
94
+ return diff;
95
+ }
96
+ return 0;
97
+ }
94
98
  function parseSemverLike(v) {
95
99
  const withoutBuild = v.trim().replace(/^v/, "").split("+")[0] ?? "";
96
100
  const [main, ...rest] = withoutBuild.split("-");