@co0ontty/wand 1.25.3 → 1.26.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 CHANGED
@@ -31,6 +31,18 @@ wand web
31
31
 
32
32
  安装完成后打开浏览器访问终端中提示的地址即可。
33
33
 
34
+ ### 升级
35
+
36
+ 推荐用同一条一键脚本升级(脚本会自动停掉正在运行的 wand 进程、清理 npm 改名残留再装最新版):
37
+
38
+ ```bash
39
+ bash <(curl -Ls https://raw.githubusercontent.com/co0ontty/wand/master/install.sh)
40
+ ```
41
+
42
+ > 也可以直接在网页设置里点「更新」按钮,或在 TUI 模式按 `u`,wand 自己会调用同样的清理逻辑。Web 端点击更新后会自动重启服务,无需手动操作。
43
+
44
+ 如果以前装过 systemd 自启服务但还是 `Restart=on-failure`(v1.25.x 前的版本),重新进入 TUI 按一次 `i` 重装服务即可换成 `Restart=always`,自动更新后才能正确拉起新进程。
45
+
34
46
  ## 功能
35
47
 
36
48
  <p align="center">
@@ -1,4 +1,4 @@
1
- import { GitStatusResult, QuickCommitResult } from "./types.js";
1
+ import { GitStatusResult, PushResult, QuickCommitResult, TagHeadResult } from "./types.js";
2
2
  export declare function getGitStatus(cwd: string): GitStatusResult;
3
3
  interface QuickCommitOptions {
4
4
  cwd: string;
@@ -20,5 +20,21 @@ export interface GenerateCommitMessageResult {
20
20
  suggestedTag?: string;
21
21
  }
22
22
  export declare function generateCommitMessageOnly(cwd: string, language: string): Promise<GenerateCommitMessageResult>;
23
+ interface TagHeadOptions {
24
+ cwd: string;
25
+ language: string;
26
+ /** Explicit tag name. If empty and `autoTag` is true, ask Claude to generate one. */
27
+ tag?: string;
28
+ autoTag?: boolean;
29
+ /** Push only this tag to its upstream remote after creating it. */
30
+ push?: boolean;
31
+ }
32
+ export declare function runTagHead(opts: TagHeadOptions): Promise<TagHeadResult>;
33
+ interface PushOptions {
34
+ cwd: string;
35
+ pushCommits?: boolean;
36
+ pushTags?: boolean;
37
+ }
38
+ export declare function runPush(opts: PushOptions): Promise<PushResult>;
23
39
  export declare function runQuickCommit(opts: QuickCommitOptions): Promise<QuickCommitResult>;
24
40
  export {};
@@ -141,6 +141,86 @@ export function getGitStatus(cwd) {
141
141
  }
142
142
  const allEntries = parsePorcelainV2(porcelain);
143
143
  const files = allEntries.slice(0, MAX_FILE_ENTRIES);
144
+ // Upstream / ahead / behind ──────────────────────────────────────────
145
+ let hasUpstream = false;
146
+ let upstream;
147
+ let ahead;
148
+ let behind;
149
+ if (!initialCommit) {
150
+ try {
151
+ upstream = runGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd) || undefined;
152
+ hasUpstream = !!upstream;
153
+ }
154
+ catch {
155
+ hasUpstream = false;
156
+ }
157
+ if (hasUpstream) {
158
+ try {
159
+ const counts = runGit(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], cwd);
160
+ const parts = counts.split(/\s+/).filter(Boolean);
161
+ if (parts.length === 2) {
162
+ const b = Number.parseInt(parts[0], 10);
163
+ const a = Number.parseInt(parts[1], 10);
164
+ if (!Number.isNaN(a))
165
+ ahead = a;
166
+ if (!Number.isNaN(b))
167
+ behind = b;
168
+ }
169
+ }
170
+ catch {
171
+ // ignore — keep counts undefined
172
+ }
173
+ }
174
+ }
175
+ // HEAD commit info ───────────────────────────────────────────────────
176
+ let lastCommit;
177
+ if (!initialCommit) {
178
+ try {
179
+ const raw = runGit(["log", "-1", "--pretty=format:%H%x09%h%x09%s"], cwd);
180
+ const parts = raw.split("\t");
181
+ if (parts.length >= 3) {
182
+ lastCommit = { hash: parts[0], shortHash: parts[1], subject: parts.slice(2).join("\t") };
183
+ }
184
+ }
185
+ catch {
186
+ // ignore
187
+ }
188
+ }
189
+ // Unpushed tag count (best-effort; remote refs may not be fresh) ─────
190
+ let unpushedTagCount;
191
+ if (hasUpstream && upstream) {
192
+ const slashIdx = upstream.indexOf("/");
193
+ const remoteName = slashIdx > 0 ? upstream.slice(0, slashIdx) : "origin";
194
+ try {
195
+ const localTagsRaw = runGit(["for-each-ref", "--format=%(refname:short)", "refs/tags"], cwd);
196
+ const localTags = localTagsRaw.split(/\r?\n/).filter((t) => t.trim().length > 0);
197
+ if (localTags.length === 0) {
198
+ unpushedTagCount = 0;
199
+ }
200
+ else {
201
+ const remoteTagsRaw = runGit(["ls-remote", "--tags", remoteName], cwd, 3000);
202
+ const remoteTags = new Set();
203
+ for (const line of remoteTagsRaw.split(/\r?\n/)) {
204
+ // format: <sha>\trefs/tags/<name>{^}? — strip annotated suffix
205
+ const tabIdx = line.indexOf("\t");
206
+ if (tabIdx === -1)
207
+ continue;
208
+ const ref = line.slice(tabIdx + 1).trim();
209
+ if (!ref.startsWith("refs/tags/"))
210
+ continue;
211
+ let name = ref.slice("refs/tags/".length);
212
+ if (name.endsWith("^{}"))
213
+ name = name.slice(0, -3);
214
+ if (name)
215
+ remoteTags.add(name);
216
+ }
217
+ unpushedTagCount = localTags.filter((t) => !remoteTags.has(t)).length;
218
+ }
219
+ }
220
+ catch {
221
+ // remote unreachable — leave undefined so UI can hide the chip
222
+ }
223
+ }
144
224
  return {
145
225
  isGit: true,
146
226
  branch,
@@ -149,6 +229,12 @@ export function getGitStatus(cwd) {
149
229
  head,
150
230
  repoRoot,
151
231
  initialCommit,
232
+ hasUpstream,
233
+ upstream,
234
+ ahead,
235
+ behind,
236
+ lastCommit,
237
+ unpushedTagCount,
152
238
  };
153
239
  }
154
240
  export class QuickCommitError extends Error {
@@ -356,7 +442,165 @@ ${diff}`;
356
442
  }
357
443
  return suggested;
358
444
  }
359
- // ── Direct git operations ──
445
+ /**
446
+ * Push current branch (with upstream auto-setup) and/or tags.
447
+ * Errors are returned via `error` so callers can present partial-success states.
448
+ */
449
+ function doPush(cwd, pushCommits, pushTags) {
450
+ let pushedCommits = false;
451
+ let pushedTags = false;
452
+ let hasUpstream = false;
453
+ let pushRemote = "origin";
454
+ try {
455
+ runGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
456
+ hasUpstream = true;
457
+ try {
458
+ const currentBranch = runGit(["branch", "--show-current"], cwd);
459
+ if (currentBranch) {
460
+ pushRemote = runGit(["config", "--get", `branch.${currentBranch}.remote`], cwd) || "origin";
461
+ }
462
+ }
463
+ catch {
464
+ pushRemote = "origin";
465
+ }
466
+ }
467
+ catch {
468
+ hasUpstream = false;
469
+ }
470
+ try {
471
+ if (pushCommits) {
472
+ if (hasUpstream) {
473
+ runGit(["push", "--recurse-submodules=on-demand"], cwd, GIT_PUSH_TIMEOUT_MS);
474
+ }
475
+ else {
476
+ runGit(["push", "-u", "--recurse-submodules=on-demand", pushRemote, "HEAD"], cwd, GIT_PUSH_TIMEOUT_MS);
477
+ }
478
+ pushedCommits = true;
479
+ }
480
+ if (pushTags) {
481
+ runGit(["push", pushRemote, "--tags"], cwd, GIT_PUSH_TIMEOUT_MS);
482
+ pushedTags = true;
483
+ }
484
+ return { pushedCommits, pushedTags };
485
+ }
486
+ catch (error) {
487
+ return { pushedCommits, pushedTags, error: getGitErrorMessage(error) };
488
+ }
489
+ }
490
+ export async function runTagHead(opts) {
491
+ const { cwd, language, tag, autoTag, push } = opts;
492
+ if (!cwd || !existsSync(cwd)) {
493
+ throw new QuickCommitError("工作目录不存在。", "CWD_MISSING");
494
+ }
495
+ let isInside;
496
+ try {
497
+ isInside = runGit(["rev-parse", "--is-inside-work-tree"], cwd);
498
+ }
499
+ catch (error) {
500
+ throw new QuickCommitError(getGitErrorMessage(error), "NOT_A_GIT_REPO");
501
+ }
502
+ if (isInside !== "true") {
503
+ throw new QuickCommitError("当前目录不在 git 仓库内。", "NOT_A_GIT_REPO");
504
+ }
505
+ // Need an existing commit to tag
506
+ let headHash;
507
+ try {
508
+ headHash = runGit(["rev-parse", "HEAD"], cwd);
509
+ }
510
+ catch {
511
+ throw new QuickCommitError("仓库还没有任何 commit,无法打 tag。", "NO_COMMIT");
512
+ }
513
+ let tagName = (tag || "").trim();
514
+ if (!tagName && autoTag) {
515
+ // Reuse the post-commit generator — it inspects `git show HEAD` directly.
516
+ let headSubject = "";
517
+ try {
518
+ headSubject = runGit(["log", "-1", "--pretty=format:%s"], cwd);
519
+ }
520
+ catch {
521
+ headSubject = "";
522
+ }
523
+ tagName = await generateTagAfterCommit(cwd, language, headSubject || "");
524
+ }
525
+ if (!tagName) {
526
+ throw new QuickCommitError("请填写 tag 名称,或开启 AI 生成。", "EMPTY_TAG");
527
+ }
528
+ // Refuse to overwrite an existing tag — surface a clear error code.
529
+ try {
530
+ runGit(["rev-parse", "--verify", `refs/tags/${tagName}`], cwd);
531
+ throw new QuickCommitError(`tag \`${tagName}\` 已存在。`, "TAG_EXISTS");
532
+ }
533
+ catch (error) {
534
+ if (error instanceof QuickCommitError)
535
+ throw error;
536
+ // not found — good, proceed
537
+ }
538
+ try {
539
+ runGit(["tag", tagName], cwd);
540
+ }
541
+ catch (error) {
542
+ throw new QuickCommitError(`git tag 失败:${getGitErrorMessage(error)}`, "GIT_TAG_FAILED");
543
+ }
544
+ let pushed = false;
545
+ let pushError;
546
+ if (push) {
547
+ let pushRemote = "origin";
548
+ try {
549
+ const currentBranch = runGit(["branch", "--show-current"], cwd);
550
+ if (currentBranch) {
551
+ try {
552
+ pushRemote = runGit(["config", "--get", `branch.${currentBranch}.remote`], cwd) || "origin";
553
+ }
554
+ catch {
555
+ pushRemote = "origin";
556
+ }
557
+ }
558
+ }
559
+ catch {
560
+ pushRemote = "origin";
561
+ }
562
+ try {
563
+ // Push just this one tag — cheaper and more targeted than `--tags`.
564
+ runGit(["push", pushRemote, `refs/tags/${tagName}`], cwd, GIT_PUSH_TIMEOUT_MS);
565
+ pushed = true;
566
+ }
567
+ catch (error) {
568
+ pushError = getGitErrorMessage(error);
569
+ }
570
+ }
571
+ return {
572
+ ok: true,
573
+ tag: { name: tagName, commit: headHash.slice(0, 7) },
574
+ pushed,
575
+ pushError,
576
+ };
577
+ }
578
+ export async function runPush(opts) {
579
+ const { cwd, pushCommits = true, pushTags = false } = opts;
580
+ if (!cwd || !existsSync(cwd)) {
581
+ throw new QuickCommitError("工作目录不存在。", "CWD_MISSING");
582
+ }
583
+ if (!pushCommits && !pushTags) {
584
+ throw new QuickCommitError("没有要推送的内容。", "NOTHING_TO_PUSH");
585
+ }
586
+ let isInside;
587
+ try {
588
+ isInside = runGit(["rev-parse", "--is-inside-work-tree"], cwd);
589
+ }
590
+ catch (error) {
591
+ throw new QuickCommitError(getGitErrorMessage(error), "NOT_A_GIT_REPO");
592
+ }
593
+ if (isInside !== "true") {
594
+ throw new QuickCommitError("当前目录不在 git 仓库内。", "NOT_A_GIT_REPO");
595
+ }
596
+ const outcome = doPush(cwd, pushCommits, pushTags);
597
+ return {
598
+ ok: !outcome.error,
599
+ pushedCommits: outcome.pushedCommits,
600
+ pushedTags: outcome.pushedTags,
601
+ error: outcome.error,
602
+ };
603
+ }
360
604
  export async function runQuickCommit(opts) {
361
605
  const { cwd, language, autoMessage, customMessage, tag, autoTag, push } = opts;
362
606
  if (!cwd || !existsSync(cwd)) {
@@ -435,37 +679,9 @@ export async function runQuickCommit(opts) {
435
679
  let pushed = false;
436
680
  let pushError;
437
681
  if (push) {
438
- try {
439
- let hasUpstream = false;
440
- let pushRemote = "origin";
441
- try {
442
- runGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
443
- hasUpstream = true;
444
- try {
445
- const currentBranch = runGit(["branch", "--show-current"], cwd);
446
- if (currentBranch) {
447
- pushRemote = runGit(["config", "--get", `branch.${currentBranch}.remote`], cwd) || "origin";
448
- }
449
- }
450
- catch {
451
- pushRemote = "origin";
452
- }
453
- }
454
- catch {
455
- hasUpstream = false;
456
- }
457
- if (hasUpstream) {
458
- runGit(["push", "--recurse-submodules=on-demand"], cwd, GIT_PUSH_TIMEOUT_MS);
459
- }
460
- else {
461
- runGit(["push", "-u", "--recurse-submodules=on-demand", pushRemote, "HEAD"], cwd, GIT_PUSH_TIMEOUT_MS);
462
- }
463
- runGit(["push", pushRemote, "--tags"], cwd, GIT_PUSH_TIMEOUT_MS);
464
- pushed = true;
465
- }
466
- catch (error) {
467
- pushError = getGitErrorMessage(error);
468
- }
682
+ const outcome = doPush(cwd, true, !!tagName);
683
+ pushed = outcome.pushedCommits && (tagName ? outcome.pushedTags : true);
684
+ pushError = outcome.error;
469
685
  }
470
686
  return {
471
687
  ok: true,
@@ -0,0 +1,51 @@
1
+ /**
2
+ * npm 全局更新通用辅助。
3
+ *
4
+ * 共用于 server.ts 的 /api/update / performAutoUpdate,以及 TUI 的 installUpdate。
5
+ *
6
+ * 解决的核心问题:当 wand 进程正在运行(systemd/launchd/nohup/直接前台都算)时,
7
+ * `npm install -g @co0ontty/wand@latest` 会把旧包目录 rename 成 `.wand-XXXXXX` 备份。
8
+ * 如果安装中途失败,这个备份目录会留下,之后每次 npm install 都会因为目标 dest 已存在
9
+ * 报 `ENOTEMPTY: directory not empty, rename ...`。
10
+ *
11
+ * 我们的策略:每次 npm install 之前先清掉 `@co0ontty/.wand-*` 残留目录;
12
+ * 如果第一次安装仍然撞上 ENOTEMPTY,清理后重试;再不行就 uninstall + force install。
13
+ */
14
+ /**
15
+ * 解析当前 `npm root -g` 的目录。失败返回 null。
16
+ */
17
+ export declare function getNpmGlobalRoot(): string | null;
18
+ /**
19
+ * 清理上一次 npm install 失败留下的 `.wand-XXXXXX` 残留目录。
20
+ *
21
+ * 同步执行,best-effort:找不到 npm root、目录不存在、无权限删除等都不会抛错。
22
+ * 返回被清理的目录列表,方便调用方记录日志。
23
+ */
24
+ export declare function cleanupNpmLeftovers(): {
25
+ removed: string[];
26
+ errors: string[];
27
+ };
28
+ /**
29
+ * 异步版本的全局安装:
30
+ * 1. 清理残留
31
+ * 2. `npm install -g <pkg>`
32
+ * 3. 撞上 ENOTEMPTY/EEXIST:再清一次 + 重试一次
33
+ * 4. 再不行:`npm uninstall -g <pkg-no-tag>` + `npm install -g --force <pkg>`
34
+ *
35
+ * @param pkg 包名带版本,例如 `@co0ontty/wand@latest`
36
+ * @param timeoutMs 单次 npm 调用超时
37
+ * @param log 可选 logger,用来把过程写入控制台或前端日志
38
+ */
39
+ export declare function installPackageGloballyAsync(pkg: string, timeoutMs: number, log?: (line: string) => void): Promise<void>;
40
+ /**
41
+ * 同步版本,给 TUI installUpdate 用。
42
+ *
43
+ * 返回值兼容 spawnSync:包含最后一次尝试的 stdout/stderr/status。
44
+ */
45
+ export declare function installPackageGloballySync(pkg: string, timeoutMs: number): {
46
+ status: number | null;
47
+ stdout: string;
48
+ stderr: string;
49
+ attempts: string[];
50
+ };
51
+ export declare const NPM_UPDATE_PACKAGE_NAME = "@co0ontty/wand";
@@ -0,0 +1,171 @@
1
+ /**
2
+ * npm 全局更新通用辅助。
3
+ *
4
+ * 共用于 server.ts 的 /api/update / performAutoUpdate,以及 TUI 的 installUpdate。
5
+ *
6
+ * 解决的核心问题:当 wand 进程正在运行(systemd/launchd/nohup/直接前台都算)时,
7
+ * `npm install -g @co0ontty/wand@latest` 会把旧包目录 rename 成 `.wand-XXXXXX` 备份。
8
+ * 如果安装中途失败,这个备份目录会留下,之后每次 npm install 都会因为目标 dest 已存在
9
+ * 报 `ENOTEMPTY: directory not empty, rename ...`。
10
+ *
11
+ * 我们的策略:每次 npm install 之前先清掉 `@co0ontty/.wand-*` 残留目录;
12
+ * 如果第一次安装仍然撞上 ENOTEMPTY,清理后重试;再不行就 uninstall + force install。
13
+ */
14
+ import { exec, spawnSync } from "node:child_process";
15
+ import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
16
+ import path from "node:path";
17
+ import { promisify } from "node:util";
18
+ const execAsync = promisify(exec);
19
+ const PACKAGE_NAME = "@co0ontty/wand";
20
+ const PACKAGE_SCOPE = "@co0ontty";
21
+ const PACKAGE_BASENAME = "wand";
22
+ /**
23
+ * 解析当前 `npm root -g` 的目录。失败返回 null。
24
+ */
25
+ export function getNpmGlobalRoot() {
26
+ try {
27
+ const res = spawnSync("npm", ["root", "-g"], { encoding: "utf8", timeout: 10_000 });
28
+ if (res.status !== 0)
29
+ return null;
30
+ const out = (res.stdout || "").trim();
31
+ return out.length > 0 ? out : null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ /**
38
+ * 清理上一次 npm install 失败留下的 `.wand-XXXXXX` 残留目录。
39
+ *
40
+ * 同步执行,best-effort:找不到 npm root、目录不存在、无权限删除等都不会抛错。
41
+ * 返回被清理的目录列表,方便调用方记录日志。
42
+ */
43
+ export function cleanupNpmLeftovers() {
44
+ const removed = [];
45
+ const errors = [];
46
+ const root = getNpmGlobalRoot();
47
+ if (!root)
48
+ return { removed, errors };
49
+ const scopeDir = path.join(root, PACKAGE_SCOPE);
50
+ if (!existsSync(scopeDir))
51
+ return { removed, errors };
52
+ let entries;
53
+ try {
54
+ entries = readdirSync(scopeDir);
55
+ }
56
+ catch (err) {
57
+ errors.push(`readdir ${scopeDir}: ${err instanceof Error ? err.message : String(err)}`);
58
+ return { removed, errors };
59
+ }
60
+ // 残留目录形如 `.wand-PdFXStca`:以点开头 + 包基名 + 短横线 + 随机后缀
61
+ const leftoverPattern = new RegExp(`^\\.${PACKAGE_BASENAME}-[A-Za-z0-9]+$`);
62
+ for (const name of entries) {
63
+ if (!leftoverPattern.test(name))
64
+ continue;
65
+ const fullPath = path.join(scopeDir, name);
66
+ try {
67
+ // 仅清理目录,避免误删同名文件
68
+ if (!statSync(fullPath).isDirectory())
69
+ continue;
70
+ rmSync(fullPath, { recursive: true, force: true });
71
+ removed.push(fullPath);
72
+ }
73
+ catch (err) {
74
+ errors.push(`rm ${fullPath}: ${err instanceof Error ? err.message : String(err)}`);
75
+ }
76
+ }
77
+ return { removed, errors };
78
+ }
79
+ /**
80
+ * 异步版本的全局安装:
81
+ * 1. 清理残留
82
+ * 2. `npm install -g <pkg>`
83
+ * 3. 撞上 ENOTEMPTY/EEXIST:再清一次 + 重试一次
84
+ * 4. 再不行:`npm uninstall -g <pkg-no-tag>` + `npm install -g --force <pkg>`
85
+ *
86
+ * @param pkg 包名带版本,例如 `@co0ontty/wand@latest`
87
+ * @param timeoutMs 单次 npm 调用超时
88
+ * @param log 可选 logger,用来把过程写入控制台或前端日志
89
+ */
90
+ export async function installPackageGloballyAsync(pkg, timeoutMs, log) {
91
+ const note = (line) => {
92
+ if (log)
93
+ log(line);
94
+ };
95
+ const cleanup = cleanupNpmLeftovers();
96
+ if (cleanup.removed.length > 0) {
97
+ note(`[wand] 清理 npm 残留目录: ${cleanup.removed.join(", ")}`);
98
+ }
99
+ try {
100
+ await execAsync(`npm install -g ${pkg}`, { timeout: timeoutMs });
101
+ return;
102
+ }
103
+ catch (error) {
104
+ const msg = error instanceof Error ? error.message : String(error);
105
+ if (!/ENOTEMPTY|EEXIST/.test(msg)) {
106
+ throw error;
107
+ }
108
+ note(`[wand] npm install 遇到 ENOTEMPTY/EEXIST,清理后重试一次...`);
109
+ }
110
+ cleanupNpmLeftovers();
111
+ try {
112
+ await execAsync(`npm install -g ${pkg}`, { timeout: timeoutMs });
113
+ return;
114
+ }
115
+ catch (error) {
116
+ const msg = error instanceof Error ? error.message : String(error);
117
+ if (!/ENOTEMPTY|EEXIST/.test(msg)) {
118
+ throw error;
119
+ }
120
+ note(`[wand] 重试仍失败,尝试先卸载再强制安装...`);
121
+ }
122
+ // 终极兜底:uninstall + force install
123
+ const baseName = pkg.replace(/@[^@/]*$/, ""); // strip @latest / @1.2.3
124
+ try {
125
+ await execAsync(`npm uninstall -g ${baseName}`, { timeout: timeoutMs });
126
+ }
127
+ catch {
128
+ /* 卸载失败也继续,下一步 --force 可能仍然能装上 */
129
+ }
130
+ cleanupNpmLeftovers();
131
+ await execAsync(`npm install -g --force ${pkg}`, { timeout: timeoutMs });
132
+ }
133
+ /**
134
+ * 同步版本,给 TUI installUpdate 用。
135
+ *
136
+ * 返回值兼容 spawnSync:包含最后一次尝试的 stdout/stderr/status。
137
+ */
138
+ export function installPackageGloballySync(pkg, timeoutMs) {
139
+ const attempts = [];
140
+ const tryInstall = (extra) => {
141
+ const args = ["install", "-g", ...extra, pkg];
142
+ attempts.push(`npm ${args.join(" ")}`);
143
+ const r = spawnSync("npm", args, { encoding: "utf8", timeout: timeoutMs });
144
+ return {
145
+ status: r.status,
146
+ stdout: r.stdout || "",
147
+ stderr: r.stderr || "",
148
+ };
149
+ };
150
+ cleanupNpmLeftovers();
151
+ let res = tryInstall([]);
152
+ if (res.status === 0)
153
+ return { ...res, attempts };
154
+ const hitENOTEMPTY = (r) => /ENOTEMPTY|EEXIST/.test(r.stdout + r.stderr);
155
+ if (!hitENOTEMPTY(res))
156
+ return { ...res, attempts };
157
+ cleanupNpmLeftovers();
158
+ res = tryInstall([]);
159
+ if (res.status === 0)
160
+ return { ...res, attempts };
161
+ if (!hitENOTEMPTY(res))
162
+ return { ...res, attempts };
163
+ // 终极兜底
164
+ const baseName = pkg.replace(/@[^@/]*$/, "");
165
+ attempts.push(`npm uninstall -g ${baseName}`);
166
+ spawnSync("npm", ["uninstall", "-g", baseName], { encoding: "utf8", timeout: timeoutMs });
167
+ cleanupNpmLeftovers();
168
+ res = tryInstall(["--force"]);
169
+ return { ...res, attempts };
170
+ }
171
+ export const NPM_UPDATE_PACKAGE_NAME = PACKAGE_NAME;
@@ -1,7 +1,7 @@
1
1
  import express from "express";
2
2
  import { SessionInputError } from "./process-manager.js";
3
3
  import { checkSessionWorktreeMergeability, cleanupSessionWorktree, getWorktreeMergeErrorCode, mergeSessionWorktree, WorktreeMergeError } from "./git-worktree.js";
4
- import { getGitStatus, QuickCommitError, runQuickCommit, generateCommitMessageOnly } from "./git-quick-commit.js";
4
+ import { getGitStatus, QuickCommitError, runQuickCommit, runTagHead, runPush, generateCommitMessageOnly, } from "./git-quick-commit.js";
5
5
  export function getErrorMessage(error, fallback) {
6
6
  return error instanceof Error ? error.message : fallback;
7
7
  }
@@ -388,6 +388,63 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
388
388
  res.status(400).json({ error: getErrorMessage(error, "生成 commit message 失败。") });
389
389
  }
390
390
  });
391
+ app.post("/api/sessions/:id/git/tag-head", express.json(), async (req, res) => {
392
+ const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
393
+ if (!snapshot) {
394
+ res.status(404).json({ error: "未找到该会话。" });
395
+ return;
396
+ }
397
+ if (!snapshot.cwd) {
398
+ res.status(400).json({ error: "会话没有工作目录。", errorCode: "NO_CWD" });
399
+ return;
400
+ }
401
+ const body = (req.body ?? {});
402
+ try {
403
+ const result = await runTagHead({
404
+ cwd: snapshot.cwd,
405
+ language: config.language ?? "",
406
+ tag: typeof body.tag === "string" ? body.tag : undefined,
407
+ autoTag: !!body.autoTag,
408
+ push: !!body.push,
409
+ });
410
+ res.json(result);
411
+ }
412
+ catch (error) {
413
+ if (error instanceof QuickCommitError) {
414
+ const status = error.code === "TAG_EXISTS" ? 409 : 400;
415
+ res.status(status).json({ error: error.message, errorCode: error.code });
416
+ return;
417
+ }
418
+ res.status(400).json({ error: getErrorMessage(error, "打 tag 失败。") });
419
+ }
420
+ });
421
+ app.post("/api/sessions/:id/git/push", express.json(), async (req, res) => {
422
+ const snapshot = getLatestSessionSnapshot(processes, structured, storage, req.params.id);
423
+ if (!snapshot) {
424
+ res.status(404).json({ error: "未找到该会话。" });
425
+ return;
426
+ }
427
+ if (!snapshot.cwd) {
428
+ res.status(400).json({ error: "会话没有工作目录。", errorCode: "NO_CWD" });
429
+ return;
430
+ }
431
+ const body = (req.body ?? {});
432
+ try {
433
+ const result = await runPush({
434
+ cwd: snapshot.cwd,
435
+ pushCommits: body.pushCommits !== false,
436
+ pushTags: !!body.pushTags,
437
+ });
438
+ res.json(result);
439
+ }
440
+ catch (error) {
441
+ if (error instanceof QuickCommitError) {
442
+ res.status(400).json({ error: error.message, errorCode: error.code });
443
+ return;
444
+ }
445
+ res.status(400).json({ error: getErrorMessage(error, "推送失败。") });
446
+ }
447
+ });
391
448
  app.post("/api/sessions/:id/worktree/cleanup", (req, res) => {
392
449
  try {
393
450
  const current = requireWorktreeSession(getLatestSessionSnapshot(processes, structured, storage, req.params.id));